Why tx.origin is risky

Solidity exposes two common transaction context variables:

  • msg.sender: the immediate caller of the current function
  • tx.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:

  1. Victim EOA starts the transaction.
  2. Victim calls attacker contract.
  3. Attacker contract calls vulnerable wallet.
  4. 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

VariableValue during exploit
tx.originVictim EOA
msg.sender in attacker contractVictim EOA
msg.sender in vulnerable walletAttacker 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 casetx.origin appropriate?Notes
Owner authorizationNoUse msg.sender or role-based access control
Contract-to-contract permissionsNoUse explicit allowlists or signed approvals
Debug loggingSometimesNever rely on it for enforcement
AnalyticsSometimesTreat as informational only
Anti-phishing protectionNoIt 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:

  1. Identify every authorization check using tx.origin.
  2. Determine the intended caller identity for each function.
  3. Replace origin-based checks with direct caller checks or explicit permissions.
  4. Add tests that simulate calls through an intermediary contract.
  5. 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.sender is 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.

Learn more with useful resources