tx.origin authentication appears in every smart contract security checklist — as something you must never do. And yet it keeps surfacing in production code, buried in inherited modifiers, copied from old tutorials, or added as a "quick anti-bot fix." The vulnerability is simple: tx.origin is always the EOA that originated the transaction, no matter how many contracts the call passes through. An attacker who gets that EOA to interact with any contract can impersonate them wherever tx.origin is used for authentication.
Six vulnerability patterns follow — from the classic drain attack to nonce collision bugs in multi-sig wallets. Each includes vulnerable Solidity, the attack path, and a corrected version.
1. Classic tx.origin Authentication Bypass
The canonical tx.origin vulnerability: an owner check that passes whenever the wallet owner is anywhere in the call chain.
// VULNERABLE: tx.origin-based owner authentication
contract Vault {
address public owner;
constructor() {
owner = msg.sender;
}
function withdrawAll(address payable recipient) external {
// tx.origin is always the EOA — passes even when called via malicious contract
require(tx.origin == owner, "not owner");
recipient.transfer(address(this).balance);
}
}
The attack. An attacker deploys a malicious NFT contract and advertises it as a free airdrop:
// ATTACKER: malicious contract disguised as an NFT airdrop
contract MaliciousAirdrop {
Vault private immutable vault;
address payable private immutable attacker;
constructor(address _vault, address payable _attacker) {
vault = Vault(_vault);
attacker = _attacker;
}
// Owner calls this expecting an NFT mint
function claim() external {
// tx.origin == owner because the owner called claim()
// This satisfies the Vault's require(tx.origin == owner)
vault.withdrawAll(attacker);
}
}
The owner calls MaliciousAirdrop.claim() to collect what they believe is a free NFT. Inside claim(), the contract calls Vault.withdrawAll(). Inside the Vault, tx.origin == owner — because the owner is the EOA that started the transaction. The require passes. The entire vault balance is transferred to the attacker.
The owner authorized nothing. They only called a contract they thought was benign.
Fix: use msg.sender for all authentication.
contract SafeVault {
address public owner;
constructor() {
owner = msg.sender;
}
function withdrawAll(address payable recipient) external {
// msg.sender is the direct caller — cannot be spoofed by intermediate contracts
require(msg.sender == owner, "not owner");
recipient.transfer(address(this).balance);
}
}
With msg.sender, the require fails when a contract calls withdrawAll — because msg.sender is the intermediate contract's address, not the owner's.
2. Wallet Contract tx.origin Assumption
Smart contract wallets — Safe, Argent, Gnosis — are common. They are contracts, not EOAs. When a Safe executes a transaction, tx.origin is whichever EOA signed and relayed the transaction, not the Safe itself. A wallet contract that uses tx.origin for internal checks breaks the moment a signer interacts with the broader DeFi ecosystem.
// VULNERABLE: smart contract wallet with tx.origin authentication
contract SimpleSmartWallet {
address public owner; // the controlling EOA
constructor(address _owner) {
owner = _owner;
}
function execute(
address target,
uint256 value,
bytes calldata data
) external returns (bytes memory) {
// Intended to allow only the owner to trigger wallet actions
require(tx.origin == owner, "not owner");
(bool success, bytes memory result) = target.call{value: value}(data);
require(success, "execution failed");
return result;
}
receive() external payable {}
}
The attack. Alice (owner) calls an unrelated protocol — a yield optimizer, a bridge, anything. That protocol calls SimpleSmartWallet.execute(attackerAddress, walletBalance, ""). Inside execute, tx.origin == Alice because she started the transaction. The require passes and the wallet is drained. Alice never touched her wallet directly.
Fix: require msg.sender == owner, and for multi-sig wallets, require the direct caller to be an authorized signer or the wallet's own execution module.
contract SafeSmartWallet {
address public owner;
constructor(address _owner) {
owner = _owner;
}
function execute(
address target,
uint256 value,
bytes calldata data
) external returns (bytes memory) {
// msg.sender must be the owner directly — no call chain bypass
require(msg.sender == owner, "not owner");
(bool success, bytes memory result) = target.call{value: value}(data);
require(success, "execution failed");
return result;
}
receive() external payable {}
}
3. tx.origin for Anti-Bot Protection
A common pattern in NFT mints and token launches: using require(tx.origin == msg.sender) to block smart contract interactions. The intent is to prevent bots from using flash loans or contract-based arbitrage during a mint window.
// VULNERABLE: using tx.origin == msg.sender as an anti-bot gate
contract NFTMint {
uint256 public constant PRICE = 0.08 ether;
mapping(address => uint256) public minted;
modifier noContracts() {
// tx.origin == msg.sender is true only when called directly from an EOA
require(tx.origin == msg.sender, "no contracts allowed");
_;
}
function mint(uint256 quantity) external payable noContracts {
require(msg.value == PRICE * quantity, "wrong price");
require(minted[msg.sender] + quantity <= 5, "mint limit");
minted[msg.sender] += quantity;
_mint(msg.sender, quantity);
}
}
This check is not a vulnerability in the same sense as the previous patterns — it does what it says. But it categorically blocks all smart contract callers: Gnosis Safe, Argent, ERC-4337 account abstraction wallets, multisigs, and any protocol integration that bundles purchases on behalf of users. Sophisticated bots can still bypass it by using EOA-controlled accounts, so the tradeoff hurts legitimate users more than it hurts bots.
Fix: if EOA-only access is truly required, document it explicitly and consider alternatives like commit-reveal, allowlists, or off-chain signatures. If you need to check whether a caller is a contract without blocking all contracts, use extcodesize selectively or implement a proper allowlist.
// BETTER: if you must restrict to EOAs, document the tradeoff clearly
// NOTE: This blocks smart wallets including Gnosis Safe and ERC-4337 accounts
modifier onlyEOA() {
require(msg.sender == tx.origin, "EOA only: smart wallets not supported");
_;
}
// PREFERRED: off-chain signature allowlist that works with all wallet types
function mintWithSignature(
uint256 quantity,
bytes calldata signature
) external payable {
bytes32 hash = keccak256(abi.encodePacked(msg.sender, quantity));
address signer = ECDSA.recover(hash, signature);
require(signer == allowlistSigner, "invalid signature");
// works with EOAs and smart wallets alike
_mint(msg.sender, quantity);
}
4. tx.origin in Modifier Chain (Inherited Bug)
Inheritance hides bugs. A base contract's onlyOwner modifier uses tx.origin. Derived contracts inherit the modifier and use it without examining its implementation. Auditors reviewing the derived contract may never look at the base.
// VULNERABLE: base contract with tx.origin in modifier
contract OwnableBase {
address public owner;
constructor() {
owner = msg.sender;
}
// Bug buried in the base — derived contracts inherit it silently
modifier onlyOwner() {
require(tx.origin == owner, "Ownable: caller is not the owner");
_;
}
}
contract Treasury is OwnableBase {
// Developer sees onlyOwner and assumes it is safe — it is not
function withdrawToken(address token, uint256 amount) external onlyOwner {
IERC20(token).transfer(owner, amount);
}
}
The attack. Identical to the classic bypass: get owner to interact with any malicious contract, which then calls Treasury.withdrawToken. The onlyOwner modifier in OwnableBase checks tx.origin == owner, which passes. Tokens are transferred to owner — but wait: an attacker who controls the recipient in the intermediate contract can redirect the transfer. More directly, the attacker's contract calls withdrawToken with a different token address pointing to a contract the attacker controls, extracting value.
Fix: always inspect inherited modifiers during audits. Use OpenZeppelin's Ownable, which correctly uses msg.sender.
// SAFE: OpenZeppelin Ownable uses msg.sender
import "@openzeppelin/contracts/access/Ownable.sol";
contract SafeTreasury is Ownable {
// onlyOwner from OpenZeppelin checks msg.sender == owner()
function withdrawToken(address token, uint256 amount) external onlyOwner {
IERC20(token).transfer(owner(), amount);
}
}
Auditor note: always trace access modifiers back to their definition. If onlyOwner lives in a base contract, read it. Run grep -r "modifier onlyOwner" . before signing off.
5. tx.origin for Nonce Tracking
Protocols sometimes use tx.origin as a per-user key for nonce tracking, replay protection, or rate limiting. This breaks when smart contract wallets are involved: multiple EOA signers can all appear as tx.origin when signing for the same Safe, creating nonce collisions.
// VULNERABLE: nonce tracking keyed on tx.origin
contract PermitProtocol {
mapping(address => uint256) public nonces;
function executeWithNonce(
address target,
bytes calldata data,
uint256 nonce
) external {
// Using tx.origin as the nonce key — breaks for contract wallets
require(nonces[tx.origin] == nonce, "invalid nonce");
nonces[tx.origin]++;
(bool success,) = target.call(data);
require(success, "call failed");
}
}
The bug. A Gnosis Safe has three signers: Alice (0xA), Bob (0xB), and Carol (0xC). Each time a signer relays a Safe transaction, tx.origin is their individual EOA. Alice relays transaction 0 — nonces[0xA] increments to 1. Bob independently relays a different Safe transaction — nonces[0xB] starts at 0, so nonce 0 is valid again. Two different Safe transactions can both use nonce 0 because they go through different signers. Replay protection is broken.
Conversely, if the protocol expects a single monotonically increasing nonce per "account" but the Safe uses multiple signers across transactions, the nonce sequence becomes inconsistent and legitimate transactions revert.
Fix: key nonces on msg.sender — the address that is directly calling the protocol. For a Safe, msg.sender is the Safe contract's address, which is consistent regardless of which EOA signed.
contract SafePermitProtocol {
mapping(address => uint256) public nonces;
function executeWithNonce(
address target,
bytes calldata data,
uint256 nonce
) external {
// msg.sender is consistent: same Safe address regardless of which signer relays
require(nonces[msg.sender] == nonce, "invalid nonce");
nonces[msg.sender]++;
(bool success,) = target.call(data);
require(success, "call failed");
}
}
6. tx.origin vs msg.sender Confusion in Permit-like Patterns
EIP-2612 permit patterns allow off-chain signatures to authorize token approvals. Protocols sometimes implement permit-like flows manually and record tx.origin as the approver instead of msg.sender. This is catastrophic: anyone who can get the approver's EOA into any transaction can drain the stored approval.
// VULNERABLE: permit-like approval using tx.origin as approver
contract BrokenPermitVault {
// approvals[approver][spender] = amount
mapping(address => mapping(address => uint256)) public approvals;
// Record an approval — but uses tx.origin instead of msg.sender
function approve(address spender, uint256 amount) external {
// tx.origin: anyone in the call chain can trigger this for the originating EOA
approvals[tx.origin][spender] = amount;
}
function transferFrom(
address from,
address to,
uint256 amount
) external {
require(approvals[from][msg.sender] >= amount, "not approved");
approvals[from][msg.sender] -= amount;
_transfer(from, to, amount);
}
}
The attack. An attacker deploys a contract and lures a victim EOA into calling it (fake airdrop, NFT claim, governance vote — anything). Inside that call, the attacker's contract calls BrokenPermitVault.approve(attackerAddress, type(uint256).max). The vault records approvals[victim][attacker] = type(uint256).max because tx.origin is the victim's EOA. The attacker then calls transferFrom(victim, attacker, victimBalance) and drains the vault.
The EIP-712 permit standard exists precisely to avoid this. It uses a signed message that binds the approval to the msg.sender who submits it (or a specific spender), with a deadline and nonce, so approvals cannot be created by tricking the owner into an unrelated transaction.
Fix: always use msg.sender as the approver in any approval or permit-like pattern.
contract SafePermitVault {
mapping(address => mapping(address => uint256)) public approvals;
function approve(address spender, uint256 amount) external {
// msg.sender is the direct caller — cannot be triggered through a call chain
approvals[msg.sender][spender] = amount;
}
function transferFrom(
address from,
address to,
uint256 amount
) external {
require(approvals[from][msg.sender] >= amount, "not approved");
approvals[from][msg.sender] -= amount;
_transfer(from, to, amount);
}
}
For any non-trivial approval or delegation system, implement EIP-712 with signed typed data. This ties the authorization to a specific signature, spender, amount, and deadline — none of which can be hijacked by a phishing contract.
What ContractScan Detects
ContractScan statically analyzes Solidity source and bytecode for all six patterns described above. The detection engine combines dataflow analysis with control-flow inspection to find both direct uses and uses hidden in inherited modifiers.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| Classic tx.origin authentication bypass | Dataflow: tx.origin flows into require or if as an authentication check |
Critical |
| Wallet contract tx.origin assumption | Control flow: tx.origin in execute/dispatch functions of contract wallets |
Critical |
| tx.origin anti-bot pattern | Pattern match: require(tx.origin == msg.sender) with context analysis |
Medium |
| tx.origin in inherited modifier | Inheritance traversal: modifier definitions in base contracts checked for tx.origin |
High |
| tx.origin nonce tracking | Dataflow: tx.origin used as mapping key for counters, nonces, or rate limits |
High |
| tx.origin in permit-like approval | Dataflow: tx.origin stored as approver in approval or allowance mappings |
Critical |
ContractScan traces tx.origin across the full call graph, including modifiers, libraries, and inherited contracts. A finding in a base contract propagates to every derived contract that uses the affected modifier.
Scan your contracts at contractscan.io before deployment.
Related Posts
- Access Control Vulnerabilities in Smart Contracts — ownership patterns, role-based access control, and the most common authorization mistakes across production DeFi protocols.
- EIP-2612 Permit Security: Signature Phishing and Front-Running — how permit signatures can be extracted from the mempool, front-run, or phished, and how to build permit flows that are resistant to each attack.
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.