← 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 solved a real UX problem. Users could sign an off-chain message to authorize a spender, removing the two-transaction approval flow.

What received less attention: permit() opened a class of security vulnerabilities that static analysis tools almost universally miss, and that auditors still frequently overlook. The root issues trace back to three properties of EIP-2612 signatures: they include a nonce (preventing replay of the same signature), a deadline (limiting validity window), and a chainId embedded in the DOMAIN_SEPARATOR (scoping signatures to a specific chain). Implementations that get any of these wrong create exploitable attack surfaces.


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).

The security implication: the signature is a self-contained authorization. If an attacker obtains it — via phishing, from a public transaction, or via a cross-chain replay — they can use it without any further interaction from the victim. The nonce increments after each use, preventing reuse of the same signature. But a signature that hasn't been used yet is fair game.


Attack Vector 1: Signature Phishing via Permit

This is the most prevalent 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 of off-chain signatures)
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:

// This code is technically correct but creates a social engineering surface
contract VulnerablePool {
    IERC20Permit public token;

    function deposit(
        uint256 amount,
        uint256 deadline,
        uint8 v, bytes32 r, bytes32 s
    ) external {
        token.permit(msg.sender, address(this), amount, deadline, v, r, s);
        token.transferFrom(msg.sender, address(this), amount);
    }
}

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-2612 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), a signature valid on chain A can be replayed on chain B if the token exists on both chains at the same address. The nonce on chain B is still 0 if the user hasn't transacted there — the attacker can use the chain A signature to drain chain B tokens.

Fix: Recompute the domain separator dynamically so block.chainid is always current:

// 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 handles this correctly using a _domainSeparatorV4() helper that caches but invalidates when block.chainid changes.


Attack Vector 3: Missing Deadline Check

The deadline parameter limits the window during which a signature can be used. But some protocol integrations don't enforce it themselves:

// VULNERABLE: deadline ignored at the integration layer
function depositWithPermit(
    uint256 amount,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    token.permit(msg.sender, address(this), amount, deadline, v, r, s);
    // The token contract checks deadline internally, but if deadline=type(uint256).max,
    // the signature is valid forever
    token.transferFrom(msg.sender, address(this), amount);
}

Setting deadline = type(uint256).max — a common frontend shortcut to avoid UX friction — creates a signature that never expires. If that signature is phished or leaked, the attacker has unlimited time to use it. Use short deadlines (20 minutes or less) 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, an 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: Wrap the permit() call in a try/catch so the deposit still proceeds if the permit was already consumed:

// 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 No No Yes
Missing deadline enforcement No No Partial Yes
No try/catch around permit() No No No Yes
Permit phishing surface No No No Yes (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, and what the expected interaction flow looks like. Pattern-based static analysis misses most of this.


Quick Audit Checklist for permit() Usage

When auditing a contract that uses permit():


Permit-based attacks are difficult to prevent purely at the contract level because many risks live in the frontend and UX layer. Contract-level hardening — dynamic domain separators, short deadlines, try/catch patterns — significantly reduces the attack surface, but the nonce, deadline, and chainId properties all need to be correct simultaneously.

Run a free scan on ContractScan to check whether your contracts have any of these patterns.

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 for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →