tx.origin authentication
tx.origin is the address that started the transaction. msg.sender is the address that made the current call. Using the former for authorisation means any contract your wallet touches can act as you.
Vulnerable code
address public owner;
function withdraw(address to, uint256 amt) external {
require(tx.origin == owner, "not owner"); // wrong check
payable(to).transfer(amt);
}
Why it breaks
An attacker deploys a contract and persuades the owner to call any function on it: an airdrop claim, an NFT mint, anything. Inside that function, the attacker's contract calls your withdraw. At that moment msg.sender is the attacker's contract, but tx.origin is still the owner's wallet, because the owner started the transaction. The check passes and the funds leave.
Watch the attack run
The same bug as a stepped sequence. Play it, or walk through with the arrow keys.
The fix
address public owner;
function withdraw(address to, uint256 amt) external {
require(msg.sender == owner, "not owner"); // direct caller
(bool ok, ) = to.call{value: amt}("");
require(ok);
}
The invariant
The fix above is what an audit gives you. This is what monitoring gives you: the thing that should always be true once the contract is live, stated precisely enough to check every block.
Privileged entry points are only ever reached from an allowlisted immediate caller.
invariant msg.sender in allowlist for every call to a privileged function alert tx.origin != msg.sender on privileged path
How to test for it
A finding you cannot reproduce is an opinion. Write the test before you write the fix, watch it fail, then make it pass.
contract Phish {
Vault v;
function claimAirdrop() external {
v.withdraw(msg.sender, address(v).balance);
}
}
function testTxOriginPhishing() public {
vm.prank(owner, owner); // sets msg.sender AND tx.origin
vm.expectRevert();
phish.claimAirdrop();
}
Where it has caused real losses
This is a well-understood class and rarely survives review, but it persists in tutorial code and copied snippets, which means it keeps reaching production in small projects. It is one of the first things we check on any contract with an owner.
What we check during review
There is one narrow legitimate use: require(msg.sender == tx.origin) to reject contract callers entirely, sometimes used as a crude anti-flash-loan measure. It is not reliable: account abstraction and EIP-7702 delegation both break the assumption, and it blocks multisigs and smart wallets, which are exactly the users you want. Prefer explicit allowlists.
Want us to check your contract for this?
A free monitoring assessment covers one contract up to 200 lines, manually reviewed, findings back within 72 hours. This class is on the checklist for every review we run.
Related vulnerabilities
Found this useful?
We publish these because the alternative, asking you to trust us, is worth less. Send a contract and we’ll apply the same thinking to your code.
- Reply written by the auditor who read your code
- No sales sequence, no drip campaign, no retargeting
- We’ll tell you if you don’t need a paid audit yet
- Report published only with your written permission