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():
- [ ] Is the domain separator computed dynamically (not cached as
immutable)? - [ ] Does the protocol enforce
deadline > block.timestampat the integration layer? - [ ] Is
permit()wrapped intry/catchto handle frontrunning? - [ ] Does the frontend use short deadlines (not
type(uint256).max)? - [ ] Does the protocol allow permit-then-transfer in a single transaction (increasing phishing risk)?
- [ ] If the token is deployed on multiple chains, are
chainIdand contract address unique per deployment?
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.