← Back to Blog

EIP-2612 Permit() and Signature Replay Attacks: The Hidden Risk in Modern DeFi Tokens

2026-04-15 solidity security permit eip-2612 signature-replay defi eip-712 phishing 2026

When EIP-2612 introduced permit() — the gasless token approval mechanism — it was celebrated as a UX improvement. Users could sign an off-chain message to authorize a spender, removing the annoying two-transaction approval flow.

What nobody fully anticipated: permit() opened a new class of security vulnerabilities that static analysis tools almost universally miss, and that smart contract auditors still frequently overlook.

In 2025 and 2026, several multi-million dollar DeFi exploits have exploited permit()-related weaknesses. This post explains how, with concrete code examples and detection strategies.


What Is permit()?

EIP-2612 extends ERC-20 with a permit() function:

function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

Instead of calling approve() on-chain, the token owner signs an off-chain EIP-712 message. Anyone (a relayer, a DeFi protocol) can then submit that signature to permit(), which internally calls approve(owner, spender, value).

Why this matters for security: The signature is a self-contained authorization. If an attacker obtains it — from a phishing site, from a public transaction, or from a cross-chain replay — they can use it without any further interaction from the victim.


Attack Vector 1: Signature Phishing via Permit

This is the most common attack in 2025-2026.

How it works:
1. User visits a malicious or compromised DeFi site
2. Site asks them to sign a "harmless-looking" message (no gas cost — users are less suspicious)
3. The message is actually a permit() signature authorizing the attacker as spender
4. Attacker submits the signature to permit(), gaining approval over the user's tokens
5. Attacker calls transferFrom() to drain the wallet

The attack requires zero on-chain interaction from the victim. Most wallet interfaces show raw EIP-712 structured data that most users don't understand.

Vulnerable pattern in protocol code:

// VULNERABLE: accepts permit from arbitrary callers without validation
contract VulnerablePool {
    IERC20Permit public token;

    function deposit(
        uint256 amount,
        uint256 deadline,
        uint8 v, bytes32 r, bytes32 s
    ) external {
        // permit() is called with msg.sender as owner — but an attacker
        // could have obtained a valid signature via phishing
        token.permit(msg.sender, address(this), amount, deadline, v, r, s);
        token.transferFrom(msg.sender, address(this), amount);
    }
}

This code is technically correct but creates a social engineering surface: any site can request a permit signature that approves the pool, and the user's wallet shows only "sign this message."


Attack Vector 2: Signature Replay Across Chains

EIP-712 includes chainId in the domain separator. But many early permit() implementations computed the domain separator at deployment time and cached it in an immutable variable:

// VULNERABLE: domain separator cached at deployment — wrong after a fork
contract OldToken {
    bytes32 public immutable DOMAIN_SEPARATOR;

    constructor() {
        DOMAIN_SEPARATOR = keccak256(abi.encode(
            keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
            keccak256(bytes(name())),
            keccak256(bytes("1")),
            block.chainid,  // captured at deploy time — stale after a chain fork
            address(this)
        ));
    }
}

After a hard fork (e.g., ETH/ETC, or a testnet deployment mirroring mainnet), a signature valid on chain A is replayed on chain B. If the token exists on both chains with the same address, the attacker can drain the victim's tokens on the second chain.

Fix: Recompute the domain separator dynamically when block.chainid might have changed:

// SAFE: domain separator computed fresh each time
function DOMAIN_SEPARATOR() public view returns (bytes32) {
    return keccak256(abi.encode(
        keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
        keccak256(bytes(name())),
        keccak256(bytes("1")),
        block.chainid,  // always current
        address(this)
    ));
}

OpenZeppelin's ERC20Permit implementation does this correctly using a _domainSeparatorV4() helper that handles the cached/dynamic split.


Attack Vector 3: Missing Deadline Check

The deadline parameter is supposed to limit the window during which a signature can be used. But some protocols that integrate permit() don't enforce it:

// VULNERABLE: deadline ignored — signature valid forever
function depositWithPermit(
    uint256 amount,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    token.permit(msg.sender, address(this), amount, deadline, v, r, s);
    // No require(block.timestamp <= deadline) check here
    // The token contract checks it internally, but if deadline=type(uint256).max,
    // the signature is valid forever — and frontrunners can grief with it
    token.transferFrom(msg.sender, address(this), amount);
}

While the ERC-20 token contract itself checks the deadline in permit(), setting deadline = type(uint256).max in the frontend (a common practice to avoid UX friction) creates a signature that never expires. If this signature is leaked or phished, the attacker has unlimited time.

Best practice: Use short deadlines (20 minutes max) and regenerate if needed.


Attack Vector 4: Frontrunning the Permit Transaction

When a user submits a transaction that includes both permit() and a subsequent action (deposit, swap), a MEV bot can observe the signature in the mempool and frontrun it:

1. User submits depositWithPermit(amount, deadline, v, r, s)
2. MEV bot sees the signature in the mempool
3. Bot submits permit(user, attacker, amount, deadline, v, r, s) first
4. User's permit() call reverts ("invalid nonce") — deposit fails
5. Bot now has approval over user's tokens

Fix: In depositWithPermit(), wrap the permit() call in a try/catch so the deposit still proceeds if permit() was already executed:

// SAFE: tolerates already-consumed permits
function depositWithPermit(
    uint256 amount,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    try token.permit(msg.sender, address(this), amount, deadline, v, r, s) {
        // permit consumed successfully
    } catch {
        // Already consumed — check that allowance is still sufficient
        require(
            token.allowance(msg.sender, address(this)) >= amount,
            "Permit failed and allowance insufficient"
        );
    }
    token.transferFrom(msg.sender, address(this), amount);
}

This is the pattern used by Uniswap v3's NonfungiblePositionManager and recommended in OpenZeppelin's SafeERC20.safePermit().


What Automated Scanners Catch (and Miss)

Issue Slither Mythril Semgrep AI
Cached domain separator ⚠️ Partial
Missing deadline enforcement ⚠️ Partial
No try/catch around permit()
Permit phishing surface ✅ (context)

The core problem: permit() vulnerabilities are semantic, not syntactic. They require understanding:
- How EIP-712 domain separators work
- What happens when a chain forks
- How MEV bots exploit mempool visibility
- What the expected interaction flow looks like

This is exactly the category where AI-based analysis — which reasons about intent and context — finds issues that pattern-based static analysis misses. ContractScan's AI engine specifically checks for these patterns.


Quick Audit Checklist for permit() Usage

When auditing a contract that uses permit():


Real-World Incidents


Permit-based attacks are difficult to prevent purely at the contract level because many of the risks live in the frontend/UX layer. But contract-level hardening — dynamic domain separators, short deadlines, try/catch patterns — significantly reduces the attack surface.

Run a free scan on ContractScan to check whether your contracts have any of these patterns. The AI engine specifically flags permit-related issues that static analyzers miss.

Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract now
Slither + AI analysis — Unlimited quick scans. No signup required.
Try Free Scan →