tx.origin vs msg.sender in Solidity: The Auth Bug That Won't Die

If your access control uses `tx.origin`, any contract your user touches can drain you. It's a 2018-era footgun that still ships in production code in 2024. Here's the exact phishing pattern attackers use, why `msg.sender` is almost always what you want, and the narrow meta-transaction cases…

`tx.origin` returns the externally-owned account (EOA) that started the transaction; `msg.sender` returns the immediate caller, which may be a contract. Using `tx.origin` for authorization is dangerous because any contract a user interacts with can call back into your contract with the user's `tx.origin`, enabling phishing attacks. Use `msg.sender` for access control. `tx.origin` is only safe for checking whether the caller is an EOA at all — and even that breaks with account abstraction.
··6 min read

The Bug in One Sentence

`tx.origin` is the EOA at the bottom of the call stack. `msg.sender` is whoever called you directly. If you authorize on `tx.origin`, you've handed your contract's keys to every contract your users will ever interact with.

This vulnerability has been documented since 2018. It still ships. I've personally flagged it in three audits in the last year — including one protocol with eight figures TVL planning mainnet launch.

How the Phishing Attack Works

Here's the vulnerable pattern. A simple wallet contract that lets the owner withdraw:

```solidity
// VULNERABLE — do not deploy
contract Wallet {
address public owner;

constructor() {
owner = msg.sender;
}

function transfer(address payable to, uint256 amount) external {
require(tx.origin == owner, "not owner");
to.transfer(amount);
}
}
```

Looks fine. Owner can withdraw, nobody else can. Right?

Now the attacker deploys this:

```solidity
contract Phish {
Wallet immutable target;
address payable immutable attacker;

constructor(Wallet _target, address payable _attacker) {
target = _target;
attacker = _attacker;
}

// Bait: a free mint, an airdrop claim, anything to get owner to call this
function claimAirdrop() external {
target.transfer(attacker, address(target).balance);
}
}
```

Attacker DMs the owner: "hey bro free NFT here, just call `claimAirdrop()`." Owner signs the transaction. The call path is:

`Owner EOA -> Phish.claimAirdrop() -> Wallet.transfer()`

Inside `Wallet.transfer()`:
- `msg.sender` = `Phish` contract address — would fail the check
- `tx.origin` = `Owner` EOA — **passes the check**

Funds drained. The owner approved nothing related to a transfer — they just clicked a button on a malicious dApp.

The Fix

Use `msg.sender`. That's it.

```solidity
contract Wallet {
address public owner;

constructor() {
owner = msg.sender;
}

function transfer(address payable to, uint256 amount) external {
require(msg.sender == owner, "not owner");
to.transfer(amount);
}
}
```

Now the attacker's phishing contract is `msg.sender`, the check fails, funds are safe. OpenZeppelin's `Ownable` does exactly this — use it instead of rolling your own.

Real-World Damage

The classic example everyone references is the Ethernaut Level 4 (`Telephone`) — a deliberate teaching exercise of this exact pattern. But it's not just CTFs.

This pattern has caused losses in:

Numerous **personal multisig wallets and treasury contracts** built before 2020 where teams rolled their own access control
- Several **token contracts** where minting or admin functions gated on `tx.origin` were exploited via malicious approval flows
- A recurring pattern in **proxy/forwarder contracts** where developers tried to "see through" a trusted forwarder using `tx.origin` instead of implementing ERC-2771 properly

Individual losses range from a few ETH up to seven figures, but unlike a single big hack, this vulnerability bleeds out across hundreds of small contracts. Static analyzers (Slither, Mythril) flag it instantly — which tells you something about the code that ships with it: it was never reviewed. A quick pass through our [free AI audit](https://www.cryptohawking.com/audit) catches this in seconds.

"But I Need to Know the User is an EOA"

This is the only legitimate-looking reason people reach for `tx.origin`. Patterns like:

```solidity
// Trying to block contract callers (e.g. anti-bot for mints)
require(tx.origin == msg.sender, "no contracts");
```

This works today, sort of. But:

1. **It's discouraged by Vitalik and the EIP authors.** Account abstraction (EIP-4337, EIP-7702) breaks the assumption that there's always an EOA at the origin. Smart contract wallets are mainstream now — Safe, Argent, Coinbase Smart Wallet. Your "anti-bot" check just blocks all of them.
2. **It doesn't even stop bots.** A determined bot deploys a contract that calls your mint directly in `constructor()` — at that moment `tx.origin == msg.sender == address(constructor caller)` is still an EOA. Use commit-reveal or allowlists instead.

The One Place tx.origin Almost Makes Sense: Meta-Transactions

In a **meta-transaction** flow (gasless transactions), a relayer pays gas to forward a signed message from a user. Inside the target contract:

`msg.sender` = the trusted forwarder contract
- `tx.origin` = the relayer's EOA (NOT the user)
- The actual user is appended to calldata, per **ERC-2771**

Notice: `tx.origin` is **still not the user**. So `tx.origin` is useless here too. The correct pattern is to inherit `ERC2771Context` and use `_msgSender()`:

```solidity
import "@openzeppelin/contracts/metatx/ERC2771Context.sol";

contract MyApp is ERC2771Context {
constructor(address trustedForwarder)
ERC2771Context(trustedForwarder) {}

function doThing() external {
address user = _msgSender(); // extracts real user from calldata
// ...
}
}
```

`_msgSender()` returns `msg.sender` for direct calls and the calldata-appended user for forwarded calls. This is the right abstraction. `tx.origin` has no role.

When `tx.origin` Has Zero Legitimate Use

Let me be specific. In production Solidity, do **not** use `tx.origin` for:

Authorization (`require(tx.origin == owner)`)
- Distinguishing users from contracts (use `_msgSender()` or commit-reveal)
- Anything in a meta-transaction flow (use ERC-2771)
- Identifying "the real user" (it's not — relayers exist)
- Bypassing reentrancy concerns (use `nonReentrant`)

The one place it appears in real codebases without being a bug is logging/analytics events — and even there, I'd push back in code review.

Audit Checklist

When reviewing a contract, grep for `tx.origin`. For each hit:

1. Is it in a `require` or `if`? Probably a bug. Replace with `msg.sender` or `_msgSender()`.
2. Is it being compared to `msg.sender` for EOA-only logic? Flag it — AA wallets will break, and it's not a real security control anyway.
3. Is it in an event? Acceptable, but consider whether you actually need it.

For protocols with significant TVL, static analysis isn't enough — call patterns through proxies, forwarders, and hooks create combinations that automated tools miss. That's the kind of thing a [manual audit](https://www.cryptohawking.com/audit/manual) catches before mainnet.

TL;DR

Use `msg.sender`. If you're building meta-transactions, use ERC-2771's `_msgSender()`. If you reached for `tx.origin`, stop and ask why — there's a 99% chance there's a better primitive. The 1% case is so niche that if you're reading this article to figure out which case you're in, you're not in it.

FAQ

Is `tx.origin` ever safe to use for authorization?

Effectively no. There is no authorization pattern where `tx.origin` is safer than `msg.sender`, and many where it's catastrophically worse. The argument "but I want the original user, not the calling contract" is exactly what the phishing attack exploits — you don't want the original EOA, you want the entity that explicitly authorized this specific call, which is `msg.sender`. For meta-transactions, ERC-2771's `_msgSender()` is the correct primitive. Treat any `tx.origin ==` check in a `require` as a bug until proven otherwise.

Will `tx.origin` work with account abstraction (EIP-4337)?

Partially, and confusingly. With EIP-4337, transactions go through the EntryPoint contract, so `tx.origin` becomes the bundler's address, not the user's smart wallet. With EIP-7702 (EOA delegation), `tx.origin` is still the EOA but it now has contract code, so `tx.origin == msg.sender` checks behave unpredictably. Any contract that depends on `tx.origin` to identify users will break for AA users entirely. Given AA adoption, treat `tx.origin`-based logic as actively incompatible with modern wallets.

How do I prevent contract callers without using `tx.origin`?

First, ask whether you actually need to. "Block contracts" is rarely a real security control — bots can call from a constructor before code exists at the address, defeating both `tx.origin == msg.sender` and `extcodesize` checks. Better options: commit-reveal schemes (force a 2-block delay), Merkle allowlists, signature-gated mints with backend verification, or per-block rate limits. If you must do EOA-only gating, accept that you're locking out smart contract wallets and document it loudly.

What's the difference between `msg.sender` and `_msgSender()`?

`msg.sender` is the EVM-level immediate caller. `_msgSender()` is an OpenZeppelin convention (in `Context` and `ERC2771Context`) that returns `msg.sender` for normal calls but extracts the real user from calldata when called via a trusted ERC-2771 forwarder. If your contract supports meta-transactions, always use `_msgSender()` for authorization checks — using raw `msg.sender` would authorize the forwarder itself, which is wrong. If you don't use meta-transactions, `msg.sender` and `_msgSender()` are equivalent, but inheriting `Context` makes future migration easier.

Do Slither and other static analyzers catch `tx.origin` bugs?

Yes — Slither's `tx-origin` detector flags any `tx.origin` use in a require/comparison with high confidence, and Mythril, Securify, and SmartCheck all detect it. If this bug ships to mainnet, it means nobody ran a free static analyzer on the code, which is itself a signal about the rest of the codebase. That said, static tools miss context: they can't tell you whether a `tx.origin` in an event is fine or whether your meta-transaction setup is correctly using ERC-2771. Static analysis is the floor, not the ceiling.

One Solidity tip + 1 case study per month