← Back to Blog

EIP-2612 Permit Security: Signature Phishing, Front-Running, and Permit Griefing

2026-04-18 eip-2612 permit signature phishing approval erc-20 solidity security

EIP-2612 was a genuine improvement to ERC-20 usability. By letting token holders sign an off-chain message that authorizes a spender, permit() eliminates the two-transaction approve-then-transfer pattern and enables completely gasless approval flows. DAI adopted it early, USDC followed, and today dozens of major DeFi protocols rely on it.

The problem is that the same property that makes permit useful—a signature that any relayer can submit—also makes it dangerous. The victim never sees a transaction. Many wallets display permit signatures as a plain "sign message" prompt with no token amount, no spender warning, and no gas fee to make the user pause. That invisibility is exactly what attackers exploit.

This post covers six distinct vulnerability classes introduced by EIP-2612 and permit-style approvals, with vulnerable Solidity examples and the defensive patterns that prevent each one.


1. Permit Phishing Attack

The most direct exploit: a malicious site presents a permit signature as a routine login or free-mint authorization. The signature is EIP-712 structured data, but the user's wallet may render it only as a hash or an opaque JSON blob.

Vulnerable flow (protocol side — no defenses):

// Attacker-controlled contract
contract PermitDrain {
    IERC20Permit public token;

    function drain(
        address victim,
        uint256 amount,
        uint256 deadline,
        uint8 v, bytes32 r, bytes32 s
    ) external {
        // Victim signed this off-chain — no on-chain transaction from victim
        token.permit(victim, address(this), amount, deadline, v, r, s);
        token.transferFrom(victim, msg.sender, amount);
    }
}

The victim signed what the malicious dApp described as "confirm your wallet ownership." The attacker submits the signature, drains the tokens, and the victim's transaction history shows nothing.

Why this is worse than approve(): A normal approve() call is a transaction. It costs gas, it appears in the wallet's pending queue, and many wallets show a warning with the spender address and amount. A permit signature costs the victim nothing and appears only as a signing request — a UI pattern users are trained to treat as safe for logins and gasless interactions.

Defensive pattern — wallets and frontends cannot protect against this at the contract level, but protocols can limit exposure:

// Limit permit deadline to prevent long-lived phished signatures
function depositWithPermit(
    uint256 amount,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    // Reject permits valid for more than 30 minutes
    require(deadline <= block.timestamp + 30 minutes, "Deadline too far");
    token.permit(msg.sender, address(this), amount, deadline, v, r, s);
    token.transferFrom(msg.sender, address(this), amount);
}

Educate users: any signature that contains a token address, spender, and amount field is a financial authorization — treat it like a transaction.


2. Permit Front-Running (Griefing / Denial of Service)

EIP-2612 nonces are per-address. Once a signature is submitted and the nonce consumed, the signature is permanently invalid. A MEV bot watching the mempool can extract the permit signature from a pending transaction and submit just the permit() call ahead of it, consuming the nonce without completing the intended action.

Vulnerable integration:

function supplyWithPermit(
    uint256 amount,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    // Front-runner calls token.permit() first — nonce is now consumed
    // This line reverts with "ERC20Permit: invalid signature"
    token.permit(msg.sender, address(this), amount, deadline, v, r, s);
    token.transferFrom(msg.sender, address(this), amount);
    _supply(msg.sender, amount);
}

The attacker gains nothing financially from this (the front-run permit still sets the allowance to address(this) for amount), but the user's transaction reverts and the user must re-sign. This is griefing: the cost to the attacker is just gas; the cost to the user is a failed transaction plus friction.

Fix — try/catch with allowance fallback:

function supplyWithPermit(
    uint256 amount,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    // Gracefully handle the case where permit was already submitted
    try token.permit(msg.sender, address(this), amount, deadline, v, r, s) {
        // Permit succeeded; allowance is now set
    } catch {
        // Permit failed — check if sufficient allowance already exists
        // (front-runner's permit set it, or user pre-approved)
        uint256 existing = token.allowance(msg.sender, address(this));
        require(existing >= amount, "Insufficient allowance");
    }
    token.transferFrom(msg.sender, address(this), amount);
    _supply(msg.sender, amount);
}

This pattern is now considered best practice for any protocol that accepts permit signatures. If the permit call reverts for any reason — front-run nonce, wrong signature, non-compliant token — the fallback to allowance() keeps the transaction alive.


3. Missing Deadline Enforcement

EIP-2612 requires a deadline parameter, and the token contract enforces block.timestamp <= deadline. But protocols accepting permits can add their own deadline validation — and should, especially for sensitive operations.

Vulnerable — protocol accepts any deadline:

function withdrawWithPermit(
    uint256 amount,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    // Accepts deadline = type(uint256).max — valid forever
    token.permit(msg.sender, address(this), amount, deadline, v, r, s);
    _withdraw(msg.sender, amount);
}

A phished signature with deadline = type(uint256).max is usable by the attacker years after the victim signed it. The victim may have forgotten the signature entirely. Because the signature never expires, it stays in the attacker's possession as a perpetual backdoor.

Fix — bound the deadline at the protocol level:

uint256 public constant MAX_PERMIT_DEADLINE = 30 minutes;

function withdrawWithPermit(
    uint256 amount,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    require(
        deadline <= block.timestamp + MAX_PERMIT_DEADLINE,
        "Permit deadline too far in future"
    );
    require(deadline >= block.timestamp, "Permit expired");
    token.permit(msg.sender, address(this), amount, deadline, v, r, s);
    _withdraw(msg.sender, amount);
}

The correct deadline window depends on the operation. A short-lived swap might enforce 5 minutes; a deposit flow might allow 1 hour. The key is that type(uint256).max should always be rejected for user-facing permit calls.


4. Permit2 Misuse — Unlimited Approval Draining

Uniswap's Permit2 contract is a universal approval hub: users approve Permit2 once per token, and then any Permit2-integrated protocol can request allowances via signed messages without additional on-chain approvals. This is powerful but expands the attack surface considerably.

The risk is that protocols using Permit2 may request or store overly broad allowances — a single compromised protocol can drain allowances granted to Permit2 for an unrelated protocol.

Vulnerable — unscoped allowance request:

// Protocol requests max allowance via Permit2 — no amount or expiration scoping
function depositViaPermit2(
    address token,
    ISignatureTransfer.PermitTransferFrom calldata permit,
    ISignatureTransfer.SignatureTransferDetails calldata transferDetails,
    bytes calldata signature
) external {
    // Transfers exactly what was signed, but protocol stores
    // a persistent allowance rather than using single-use transfer
    PERMIT2.permitTransferFrom(permit, transferDetails, msg.sender, signature);
    // Stores open-ended allowance for future use — dangerous
    _persistAllowance(msg.sender, token, type(uint256).max);
}

Fix — use single-use SignatureTransfer and scope all allowances:

function depositViaPermit2(
    ISignatureTransfer.PermitTransferFrom calldata permit,
    ISignatureTransfer.SignatureTransferDetails calldata transferDetails,
    bytes calldata signature
) external {
    // Single-use transfer: nonce consumed after one use, no persistent allowance
    PERMIT2.permitTransferFrom(permit, transferDetails, msg.sender, signature);
    // Do NOT store any allowance — each action requires a fresh signature
    _recordDeposit(msg.sender, transferDetails.requestedAmount);
}

When Permit2 AllowanceTransfer (persistent allowances) is used, always set a short expiration and the minimum required amount. Prefer SignatureTransfer for single operations — it consumes the signature's nonce immediately and leaves no residual approval.


5. Signature Replay Across Chains

EIP-2612 binds signatures to a specific chain by including chainId in the DOMAIN_SEPARATOR. However, contracts that compute the domain separator at deploy time and cache it face a subtle vulnerability: if the contract is deployed to a chain that later forks, or if the same bytecode is deployed at the same address on an EVM-compatible chain (BSC, Polygon, Avalanche), the cached domain separator may be identical on both chains.

Vulnerable — cached domain separator:

contract MyToken is ERC20 {
    bytes32 private immutable DOMAIN_SEPARATOR;

    constructor() {
        // Computed once at deploy time, never updated
        DOMAIN_SEPARATOR = keccak256(abi.encode(
            keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
            keccak256(bytes(name())),
            keccak256(bytes("1")),
            block.chainid,   // Correct at deploy time, but immutable
            address(this)
        ));
    }
}

If this contract is deployed at the same address on Ethereum mainnet and BSC (via CREATE2 with the same salt), a permit signature issued on mainnet is valid on BSC too — the domain separator is byte-for-byte identical.

Fix — recompute domain separator dynamically:

function DOMAIN_SEPARATOR() public view returns (bytes32) {
    // Recomputed on every call — always reflects current chainId
    return keccak256(abi.encode(
        keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
        keccak256(bytes(name())),
        keccak256(bytes("1")),
        block.chainid,
        address(this)
    ));
}

OpenZeppelin's ERC20Permit implementation uses a hybrid: it stores the chain ID at construction and returns the cached value unless the current chain ID differs, at which point it recomputes. This handles both the common case efficiently and the chain-fork edge case correctly.


6. Integrating Permit Without Checking Return Value

Not every ERC-20 that declares a permit() function implements it correctly. Some tokens implement permit() as a no-op to satisfy interface requirements without actually setting an allowance. Others revert silently through a non-standard path. Protocols that blindly call permit() and then transferFrom() without verifying the resulting allowance may behave in unexpected ways.

Vulnerable — assumes permit worked:

function deposit(
    uint256 amount,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    // If token.permit() is a no-op, this sets allowance to 0
    token.permit(msg.sender, address(this), amount, deadline, v, r, s);
    // transferFrom will revert if allowance wasn't set — but may succeed
    // if user had a pre-existing allowance from a different approval flow,
    // debiting an approval the user did not intend for this call
    token.transferFrom(msg.sender, address(this), amount);
}

Two failure modes: if there is no pre-existing allowance, transferFrom reverts and the protocol appears broken. If there is a pre-existing allowance from an unrelated flow, the transfer silently succeeds using that allowance — an unexpected debit the user did not authorize for this specific action.

Fix — validate allowance after permit:

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

    // Always verify allowance is sufficient regardless of permit outcome
    require(
        token.allowance(msg.sender, address(this)) >= amount,
        "Insufficient allowance after permit"
    );
    token.transferFrom(msg.sender, address(this), amount);
}

This pattern is robust against no-op permit implementations, front-run nonces, and malformed signatures. The explicit allowance() check ensures the protocol never proceeds on an assumption — it proceeds only on verified state.


What ContractScan Detects

ContractScan analyzes Solidity source and compiled bytecode for permit-related vulnerability patterns automatically.

Vulnerability Detection Method Severity
Permit phishing surface (no deadline bound) Static analysis: permit() call with no deadline validation in caller High
Front-running griefing (no try/catch) Control flow analysis: permit() not wrapped in try/catch before transferFrom() Medium
Unbounded deadline acceptance Data flow: deadline parameter not compared against block.timestamp + constant High
Permit2 unscoped allowance Pattern match: AllowanceTransfer with type(uint256).max amount or no expiry High
Cached domain separator with chain replay risk AST analysis: DOMAIN_SEPARATOR stored as immutable without dynamic fallback Medium
No-op permit without allowance check Taint analysis: transferFrom() called after permit() without allowance() guard Medium

Run a scan at contractscan.io to detect these patterns in your contracts before deployment.


Defensive Summary

Before deploying any contract that accepts permit() calls:

  1. Bound the deadline — reject type(uint256).max and any deadline beyond a reasonable window for the operation.
  2. Wrap permit in try/catch — fall back to the existing allowance so front-running cannot cause a denial of service.
  3. Validate allowance after permit — never assume permit() succeeded; always read the resulting allowance.
  4. Use dynamic domain separator computation — do not cache DOMAIN_SEPARATOR as an immutable if the contract may exist on multiple chains.
  5. Prefer Permit2 SignatureTransfer over AllowanceTransfer — single-use signatures leave no residual approval surface.

EIP-2612 is a well-designed standard, but its security model shifts significant responsibility to integrating protocols. The token itself may enforce nonces and deadlines correctly while the protocol calling it introduces every one of the vulnerabilities above. Audit both layers.


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 →