
Secure Use of `tx.origin` in Solidity: Avoiding Phishing-Style Authorization Bugs
Why tx.origin is risky
Solidity exposes two common transaction context variables:
msg.sender: the immediate caller of the current functiontx.origin: the original externally owned account (EOA) that started the transaction
The key difference is that tx.origin remains the same across all nested calls in a transaction, while msg.sender changes at each call boundary.
That behavior makes tx.origin unsuitable for authorization. If your contract checks tx.origin == owner, then any malicious contract can trick the owner into calling it, and the malicious contract can then call your contract on the owner’s behalf. The check passes because the transaction still originated from the owner.
The core mistake
require(tx.origin == owner, "not authorized");This is not a safe ownership check. It does not verify who is directly calling the function. It only verifies who started the transaction.
How the attack works
Consider a wallet contract that uses tx.origin to protect a withdrawal function. An attacker deploys a malicious contract that calls the wallet. The attacker then persuades the victim owner to interact with the malicious contract, perhaps through a fake airdrop claim, NFT mint, or DeFi interface.
When the victim triggers the malicious contract, the call chain looks like this:
- Victim EOA starts the transaction.
- Victim calls attacker contract.
- Attacker contract calls vulnerable wallet.
- Wallet sees
tx.origin == victim, so the authorization check passes.
The wallet sends funds to the attacker, even though the victim never intended to authorize that specific action.
Call-chain illustration
| Variable | Value during exploit |
|---|---|
tx.origin | Victim EOA |
msg.sender in attacker contract | Victim EOA |
msg.sender in vulnerable wallet | Attacker contract |
The wallet should have checked the immediate caller, not the transaction origin.
A vulnerable example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract VulnerableVault {
address public owner;
constructor() {
owner = msg.sender;
}
function withdraw(address payable to, uint256 amount) external {
require(tx.origin == owner, "not owner");
require(address(this).balance >= amount, "insufficient balance");
(bool ok, ) = to.call{value: amount}("");
require(ok, "transfer failed");
}
receive() external payable {}
}At first glance, this looks like a simple owner-only withdrawal function. In reality, it is vulnerable because tx.origin can be preserved through an attacker-controlled intermediary contract.
Attack contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IVulnerableVault {
function withdraw(address payable to, uint256 amount) external;
}
contract PhishingContract {
IVulnerableVault public vault;
address payable public attacker;
constructor(address vaultAddress, address payable attackerAddress) {
vault = IVulnerableVault(vaultAddress);
attacker = attackerAddress;
}
function trickVictim() external {
vault.withdraw(attacker, address(vault).balance);
}
}If the victim calls trickVictim(), the vault’s withdraw() function sees tx.origin as the victim and releases the funds to the attacker.
The correct approach: use msg.sender
For authorization, the direct caller is almost always what you want. Replace tx.origin checks with msg.sender checks and use a clear ownership model.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SafeVault {
address public owner;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function withdraw(address payable to, uint256 amount) external onlyOwner {
require(address(this).balance >= amount, "insufficient balance");
(bool ok, ) = to.call{value: amount}("");
require(ok, "transfer failed");
}
receive() external payable {}
}This version is safer because only the direct caller can pass the authorization check. A malicious intermediary contract cannot impersonate the owner unless the owner explicitly grants it permission through some other mechanism.
When tx.origin is sometimes seen
You may encounter tx.origin in legacy code, tutorials, or quick prototypes. It is occasionally used for:
- debugging or logging transaction provenance
- legacy anti-bot heuristics
- rough analytics about end-user origin
Even in these cases, it should not be used for security decisions. If a contract’s behavior changes based on who is “really behind” the call, you are usually building an authorization rule. That rule should not depend on tx.origin.
Safe vs unsafe uses
| Use case | tx.origin appropriate? | Notes |
|---|---|---|
| Owner authorization | No | Use msg.sender or role-based access control |
| Contract-to-contract permissions | No | Use explicit allowlists or signed approvals |
| Debug logging | Sometimes | Never rely on it for enforcement |
| Analytics | Sometimes | Treat as informational only |
| Anti-phishing protection | No | It creates the very issue it tries to solve |
Common real-world failure modes
1. “Only EOA” checks
Some developers try to block contracts by requiring tx.origin == msg.sender. This is brittle and not a reliable security boundary. It can break legitimate smart wallet integrations, account abstraction flows, multisigs, and relayers.
require(tx.origin == msg.sender, "contracts not allowed");This is not a robust defense. It may exclude valid users while still failing to stop sophisticated attacks.
2. Owner checks in helper contracts
A helper contract might call into a vault, token, or staking contract. If the callee uses tx.origin, the helper becomes a phishing bridge.
This is especially dangerous when:
- the user signs a transaction through a frontend they do not fully trust
- the frontend routes through multiple contracts
- the contract is designed to interact with other protocols
3. Multisig and smart wallet incompatibility
Modern Ethereum users often interact through smart wallets rather than EOAs. tx.origin assumes a single human-controlled EOA is always the source of truth, which is increasingly false. Using it can make your contract incompatible with legitimate account abstractions and custody solutions.
Better authorization patterns
Ownable access control
For single-admin contracts, use a standard owner model with msg.sender.
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}This is simple, explicit, and easy to audit.
Role-based access control
For larger systems, use roles instead of a single owner. OpenZeppelin’s AccessControl is a common choice.
Conceptually, the rule is still based on msg.sender, but the authorization logic becomes more flexible and maintainable.
Signature-based approvals
If you need delegated authorization, use signed messages with explicit intent, nonce tracking, and domain separation. This is much safer than inferring intent from tx.origin.
Allowlist trusted contracts
If a contract must interact only with specific protocol contracts, maintain an explicit allowlist.
mapping(address => bool) public approvedSpenders;
function setApprovedSpender(address spender, bool approved) external onlyOwner {
approvedSpenders[spender] = approved;
}This is clearer than trying to infer trust from the transaction origin.
Refactoring a vulnerable contract
Suppose you inherited a contract that uses tx.origin in multiple places. A safe migration strategy is:
- Identify every authorization check using
tx.origin. - Determine the intended caller identity for each function.
- Replace origin-based checks with direct caller checks or explicit permissions.
- Add tests that simulate calls through an intermediary contract.
- Review any external call paths that could be used as a phishing bridge.
Example refactor
Before:
require(tx.origin == admin, "not admin");After:
require(msg.sender == admin, "not admin");If the function must be callable by a trusted automation contract, use an explicit allowlist:
require(msg.sender == admin || msg.sender == automationBot, "not authorized");This makes the trust model visible in code and easier to audit.
Testing for tx.origin vulnerabilities
A good test suite should include an intermediary contract that mimics an attacker-controlled relay. If the protected function can be reached through the relay, the authorization model is likely wrong.
What to test
- direct EOA calls from the owner
- direct EOA calls from non-owners
- calls through a malicious intermediary contract
- calls through a legitimate multisig or smart wallet, if supported
- behavior after ownership transfer
Practical test idea
Write one test where the owner calls the attacker contract, and the attacker contract calls the target. If the target authorizes based on tx.origin, the test will pass when it should fail.
That test should be part of every security review for contracts that manage assets or permissions.
Security review checklist
Before deploying, verify the following:
- No authorization logic depends on
tx.origin msg.senderis used for direct caller checks- Role and ownership changes are explicit and evented
- Contract-to-contract integrations use allowlists or signed approvals
- Tests include intermediary contract call paths
- The design remains compatible with smart wallets and multisigs
If any of these items are missing, the contract may be vulnerable to phishing-style authorization bypasses.
Summary
tx.origin is not a secure basis for authorization in Solidity. It tracks the original transaction sender, not the immediate caller, which makes it exploitable through intermediary contracts. In security-sensitive code, use msg.sender for direct authorization, and use explicit roles, allowlists, or signed approvals when delegation is required.
The safest rule is simple: if you are deciding whether a function should be allowed, do not ask who started the transaction—ask who is calling the function now.
