Foundation news
Author
Leigh McCulloch
Publishing date
Authorization is one of those things that seems simple until you try to build a real application. Who can call this function? Who pays the fees? What happens when Contract A calls Contract B on behalf of a user? Stellar’s authorization framework answers all of these questions with a design that is both minimal and remarkably flexible.
At its core, the model rests on three ideas: contracts declare what needs authorization, the runtime handles how that authorization is verified, and the authorization data itself is detachable from the transaction. This separation unlocks patterns like sponsored transactions, multi-party atomic swaps, and programmable accounts that are painful or impossible in most smart contract platforms.
A Stellar contract never sees signatures or credentials directly. Instead, it calls require_auth() on an Address:
pub fn withdraw(env: Env, owner: Address, amount: i128) {
owner.require_auth();
// ... transfer logic
}
That single line is the contract's entire security policy for this function. The runtime takes care of the rest: matching the require_auth call against authorization entries attached to the transaction, verifying signatures, checking nonces, preventing replay, and enforcing expiration.
This separation means contracts are simple to write and audit. The authorization logic is declarative ("this address must have authorized this call") and the cryptographic machinery lives outside the contract.
In most blockchains, authorization is welded to the transaction. The person who signs the transaction is the person who pays fees, and that's the person who authorized the entire operation.
Ethereum illustrates the tension well. The base model is msg.sender: whoever sent the transaction (and paid gas) is the authorized party. This is simple but rigid: if Alice wants Bob to act on her behalf, there's no built-in way to express that. The ecosystem bolted on solutions over time. ERC-20 approve/transferFrom lets you pre-authorize a spender, but it's a separate on-chain transaction with its own gas cost, and over-approval is a well-known footgun. EIP-2612 permit added off-chain signatures for approvals, which is closer to detachable auth, but it's token-specific, not a general framework. ERC-4337 account abstraction introduced a whole parallel transaction pipeline (bundlers, paymasters, user operations) to decouple fee payment from authorization. Each of these is a point solution layered on top of a model that didn't account for the problem originally.
Stellar handles all of these cases (msg.sender-style caller auth, permit-style detached signatures, and fee abstraction) with a mechanism built into Stellar’s smart contract runtime, Soroban.
A Stellar transaction carries an array of SorobanAuthorizationEntry items alongside the contract invocation. Each entry is independently signed by the party it represents, and is structurally separate from the transaction envelope signature. This means:
The transaction structure looks like this:
TransactionEnvelope
Transaction
Operations[0]: InvokeHostFunctionOp
hostFunction: invokeContract(swap, "execute",
[alice, bob, token_a, 100, token_b, 50])
auth: [
SorobanAuthorizationEntry { // Alice's auth
credentials: ADDRESS { alice, nonce, expiration, signature },
rootInvocation: swap.execute(alice, bob, token_a, 100, token_b, 50)
└── token_a.transfer(alice, bob, 100)
},
SorobanAuthorizationEntry { // Bob's auth
credentials: ADDRESS { bob, nonce, expiration, signature },
rootInvocation: swap.execute(alice, bob, token_a, 100, token_b, 50)
└── token_b.transfer(bob, alice, 50)
}
]
Signatures: [envelope_sig_from_coordinator] // fee-payer
Alice and Bob each signed only the auth tree relevant to them. The coordinator signed the transaction envelope. Three independent signatures, one atomic transaction.
Note that for simple cases, when the transaction source account
is the authorizer, you can use SOROBAN_CREDENTIALS_SOURCE_ACCOUNT credentials, which derive authorization from the envelope signature. This avoids a separate auth entry signature but sacrifices detachability. It's useful for simple cases where the caller pays their own fees and a single authorizer is involved with the transaction.
A critical distinction in Soroban's model: the auth tree that a user signs is not the same as the invocation tree (the actual call stack of contract functions). The auth tree contains only the functions that call require_auth for that address. It may overlap with the invocation tree, but it doesn't have to.
Consider a DEX contract that allows acquiring a token:
pub fn buy(env: Env, buyer: Address, token: Address, amount: i128) {
buyer.require_auth();
token::Client::new(&env, &token).transfer(&buyer, &env.current_contract_address(), &amount);
}
Both dex.buy and token.transfer call require_auth(buyer). The buyer's auth tree covers both:
auth tree for buyer:
dex.buy(buyer, token, amount)
└── token.transfer(buyer, dex, amount)
The buyer signs this auth tree. The dex.buy node is the root of the auth tree, and it happens to also be the root of the invocation tree. This is "root auth." This is also auth where all invocations in the path are included in the auth tree.
Now consider what happens if the DEX contract doesn't call require_auth:
pub fn buy(env: Env, buyer: Address, token: Address, amount: i128) {
// No require_auth here!
token::Client::new(&env, &token).transfer(&buyer, &env.current_contract_address(), &amount);
}
The invocation tree is the same (dex.buy calls token.transfer), but the auth tree is different. Since dex.buy never calls require_auth(buyer), it doesn't appear in the auth tree. The buyer's auth tree is just:
auth tree for buyer:
token.transfer(buyer, dex, amount)
This is "non-root auth." The root of the buyer's auth tree (token.transfer) is a sub-invocation in the invocation tree, not the top-level call. The user is authorizing the token transfer directly independent of any other operations. The calls authorized can be detached from the rest of the call tree. This means the user is saying that the transfer can happen with or without those other calls. If the buy function updated state to do any accounting of the buy, this could be a serious bug. It’s important that a developer is careful about when to use non-root auth.
Non-root auth provides flexibility. Because auth entries don't need to be rooted at the top-level invocation, a user can sign standalone authorization for specific operations (like a token transfer) and hand those signed entries to anyone to include in any transaction. For example, a bundler could include executions from others.
A user can sign several independent auth entries (a token approval here, a transfer there) and a coordinator can bundle them all into a single transaction alongside other operations. The auth entries don't need to know about each other or about the top-level invocation that ties them together if they are not intended to be atomic.
In Ethereum, achieving something similar requires jumping through hoops. You'd need a combination of EIP-2612 permit signatures (only available for tokens that implement it), a multicall or batching contract to execute them atomically, and careful orchestration to ensure the permits and the operations that consume them land in the right order within the same transaction. Each token might implement permits differently (or not at all), and the batching contract needs explicit support for every operation type. Soroban's non-root auth handles this generically. Any require_auth call in any contract can be satisfied by a detached auth entry, no special token standards or batching infrastructure required.
require_auth at every level mattersThe auth tree only contains nodes where require_auth is called. This means every require_auth call in the chain adds a node to the tree, giving the authorizer visibility and control over the context. If an intermediate contract skips require_auth, its call is invisible in the auth tree, and the authorizer can't distinguish whether the sub-call happened in one context or another. This might be okay if that sub-call is an unimportant detail of the call sequence.
Consider three contracts in a chain: a router, a pool, and a token:
// Router contract
pub fn swap(env: Env, user: Address, pool: Address, amount: i128) {
user.require_auth(); // ← (1) this anchors the auth tree
pool::Client::new(&env, &pool).buy(&user, &amount);
}
// Pool contract
pub fn buy(env: Env, user: Address, amount: i128) {
user.require_auth(); // ← (2) this retains pool.buy in the auth tree
token_client.transfer(&user, &env.current_contract_address(), &amount);
}
The user's auth tree is:
router.swap(user, pool, amount)
└── pool.buy(user, amount)
└── token.transfer(user, pool, amount)
The user can see exactly what they're authorizing: a swap on this router, buying into this pool, transferring this amount. Now imagine the pool omits its require_auth:
// Pool contract (missing require_auth)
pub fn buy(env: Env, user: Address, amount: i128) {
// No require_auth!
token_client.transfer(&user, &env.current_contract_address(), &amount);
}
The auth tree collapses. pool.buy is pruned because it never called require_auth(user):
router.swap(user, pool, amount)
└── token.transfer(user, pool, amount)
The pool.buy node is gone. The user authorized a transfer to the pool, but there's no record in the auth tree that it went through the pool's buy function specifically. If the pool had multiple functions that all called token.transfer with the same arguments, the auth tree couldn't distinguish between them.
Worse, if the router also omits require_auth, the auth tree becomes just:
token.transfer(user, pool, amount)
Now the user is authorizing a bare token transfer with no context at all. Any contract calling token.transfer(user, pool, amount) would satisfy this auth. The user has no assurance about why their tokens are moving.
The rule: every contract in the call chain that is important to the authorizer must call require_auth. Each call adds a node to the auth tree, and each node gives the signer context about what they're authorizing and in what circumstances.
There's an important optimization for contract-to-contract calls. When Contract A directly calls Contract B, and B calls require_auth(address_of_A), the authorization succeeds automatically with no signature needed. The runtime knows A is the direct caller, so no external proof is required.
This is the foundation of composability. A contract that holds tokens can authorize their use in downstream calls simply by being the caller:
// Inside Contract A
pub fn process(env: Env) {
// Contract A holds tokens. When it calls transfer, the token contract
// will require_auth on A's address, which succeeds automatically
// because A is the direct caller.
token_client.transfer(&env.current_contract_address(), &recipient, &amount);
}
This only works for the direct caller. If A calls B calls C, and C calls
require_auth(address_of_A), it won't auto-succeed because A isn't C's direct caller. For deeper chains, contracts use authorize_as_current_contract:
pub fn process(env: Env, recipient: Address, amount: i128) {
let my_addr = env.current_contract_address();
// Pre-authorize the deeper call that B will make on our behalf
env.authorize_as_current_contract(vec![
&env,
InvokerContractAuthEntry::Contract(SubContractInvocation {
context: ContractContext {
contract: token_addr.clone(),
fn_name: symbol_short!("transfer"),
args: (my_addr.clone(), recipient.clone(), amount).into_val(&env),
},
sub_invocations: vec![&env],
}),
]);
// B will call token.transfer(my_addr, recipient, amount) internally
contract_b_client.route_payment(&my_addr, &recipient, &amount);
}
Sometimes you want to authorize something other than the exact function arguments. require_auth_for_args lets you specify custom authorization arguments:
pub fn batch_mint(env: Env, transfers: Vec<Transfer>) {
let admin = env.storage().instance().get(DataKey::Admin).unwrap();
// Authorize the entire batch as a unit, not each individual transfer,
// and include the token as part of the signature payload so that
// so that the signature is only valid for the token the contract is
// currently configured with.
admin.require_auth_for_args(
(
symbol_short!("batch"),
token_client.address(),
transfers.clone(),
).into_val(&env)
);
let token = env.storage().instance().get(DataKey::Token).unwrap();
let token_client = TokenClient::new(&env, &token);
for t in transfers.iter() {
token_client.mint(&t.to, &t.amount);
}
}
This is useful when the authorization semantics don't map cleanly to the function signature: authorizing a subset of arguments, authorizing a computed value, authorizing a stored value, or making multiple require_auth calls in the same function for different purposes.
Soroban's Address type is abstract. It can represent a classic Stellar account (G-address with ed25519 keys) or a contract account (C-address). Contract accounts implement a __check_auth function that defines arbitrary authentication logic:
impl CustomAccountInterface for MyWallet {
fn __check_auth(
env: Env,
signature_payload: BytesN<32>,
signatures: Val,
auth_contexts: Vec<Context>,
) -> Result<(), Error> {
// Any logic you want:
// - WebAuthn / passkey verification
// - Multi-sig with weighted signers
// - Spending limits per time period
// - Policy-based access control
// - Social recovery
}
}
The host calls __check_auth automatically whenever require_auth is invoked on the contract account's address. The auth_contexts parameter tells the account what is being authorized, so it can enforce fine-grained policies like "allow up to 1000 USDC per day without additional approval."
Because this uses the same require_auth mechanism, contract accounts work everywhere that regular accounts work. A DeFi protocol doesn't need special code to support smart wallets. It just calls require_auth, and the runtime routes to the right verification logic.
A mobile wallet app where users never hold XLM:
The user's auth entry is bound to the specific call, nonce, and expiration, so the backend can't repurpose it for anything else.
An OTC desk matching two counterparties:
pub fn execute_swap(
env: Env,
alice: Address,
bob: Address,
token_a: Address,
amount_a: i128,
token_b: Address,
amount_b: i128,
) {
alice.require_auth();
bob.require_auth();
token::Client::new(&env, &token_a).transfer(&alice, &bob, &amount_a);
token::Client::new(&env, &token_b).transfer(&bob, &alice, &amount_b);
}
Alice signs an auth tree covering swap.execute and her token_a transfer. Bob signs one covering swap.execute and his token_b transfer. The desk submits both in one transaction. Either both transfers happen or neither does.
A multi-protocol router that deposits into multiple protocols:
pub fn deposit(env: Env, user: Address, amount: i128) {
user.require_auth();
// Pull tokens from user
token_client.transfer(&user, &env.current_contract_address(), &amount);
// Split across protocols. These calls use automatic invoker auth
// because this contract is the direct caller and holds the tokens
let half = amount / 2;
protocol_a_client.deposit(&env.current_contract_address(), &half);
protocol_b_client.deposit(&env.current_contract_address(), &(amount - half));
}
The user signs one auth tree covering the top-level deposit and the token transfer. The contract's subsequent calls to Protocol A and Protocol B are authorized automatically because the contract is the direct caller moving its own tokens.
A contract account that issues limited-session authorization:
fn __check_auth(
env: Env,
signature_payload: BytesN<32>,
signatures: Val,
auth_contexts: Vec<Context>,
) -> Result<(), Error> {
let sig: SessionSignature = signatures.try_into()?;
// Verify the session key is active and not expired
let session = get_session(&env, &sig.session_key)?;
verify_not_expired(&env, &session)?;
// Enforce session policy: only allow specific contracts/functions
for ctx in auth_contexts.iter() {
enforce_session_policy(&session, &ctx)?;
}
// Verify the session key's signature
env.crypto().ed25519_verify(&sig.session_key, &signature_payload, &sig.signature);
Ok(())
}
The user's main key creates a session with a limited policy. The client uses the session key for transactions without prompting the user's main key for every action.
Soroban's auth model achieves composability through separation of concerns:
require_authDetachable auth decouples authorization from fee payment. Authorization trees make cross-contract calls safe and auditable. Automatic invoker auth makes contract-to-contract composition frictionless. And programmable accounts let any verification logic plug into the same framework.
The result is a system where a simple owner.require_auth() in your contract automatically supports sponsored transactions, multi-party coordination, smart wallets, session keys, and arbitrary composition, without your contract knowing or caring about any of it.