SEP-10 defines a standard authentication flow for Stellar anchors using challenge transactions. This protocol enables secure, wallet-based authentication without requiring passwords or traditional credentials.
Overview
SEP-10 authentication works through a challenge-response mechanism where:
Client requests a challenge transaction from the anchor
Anchor generates a transaction with specific requirements
Client signs the transaction with their private key
Anchor verifies the signature and issues a JWT token
Why SEP-10?
Passwordless : No need to store or manage passwords
Cryptographic : Leverages Stellar’s signing infrastructure
Standardized : Consistent across all compliant anchors
Secure : Short-lived tokens with configurable expiration
Implementation
PayOnProof implements SEP-10 authentication in services/api/lib/stellar/sep10.ts:
Request token flow
services/api/lib/stellar/sep10.ts
export async function requestSep10Token (
input : Sep10TokenInput
) : Promise < Sep10TokenResult > {
let domain = input . domain ?. trim ();
let webAuthEndpoint = input . webAuthEndpoint ?. trim ();
let serverSigningKey = input . serverSigningKey ?. trim ();
// Auto-discover endpoint if not provided
if ( ! webAuthEndpoint ) {
if ( ! domain ) {
throw new Error ( "Provide domain or webAuthEndpoint" );
}
const discovered = await discoverAnchorFromDomain ({ domain });
webAuthEndpoint = discovered . webAuthEndpoint ;
domain = discovered . domain ;
serverSigningKey = serverSigningKey || discovered . signingKey ;
}
if ( ! webAuthEndpoint ) {
throw new Error ( "WEB_AUTH_ENDPOINT not found in stellar.toml" );
}
if ( ! serverSigningKey ) {
throw new Error (
"Missing SIGNING_KEY for SEP-10 verification. Provide domain or serverSigningKey."
);
}
const keypair = Keypair . fromSecret ( input . secretKey . trim ());
const account = input . accountPublicKey ?. trim () || keypair . publicKey ();
// ... challenge request and verification
}
Full implementation at services/api/lib/stellar/sep10.ts:53
Challenge request
The authentication process starts by requesting a challenge transaction:
const authBase = normalizeBaseUrl ( webAuthEndpoint );
let challengeUrl = appendQuery ( authBase , "account" , account );
challengeUrl = appendQuery ( challengeUrl , "home_domain" , input . homeDomain );
challengeUrl = appendQuery ( challengeUrl , "client_domain" , input . clientDomain );
const challengeRes = await fetchWithTimeout (
challengeUrl ,
{ method: "GET" , headers: { Accept: "application/json" } },
input . timeoutMs ?? DEFAULT_TIMEOUT_MS
);
const challengeJson = await challengeRes . json () as {
transaction ?: string ;
network_passphrase ?: string ;
};
Query parameters
account : The Stellar account requesting authentication
home_domain : Optional domain of the client application
client_domain : Optional domain for client verification
The challenge request is a simple GET request. The anchor returns a base64-encoded transaction for the client to sign.
Challenge verification
Before signing, the client must verify the challenge transaction:
const networkPassphrase =
challengeJson . network_passphrase || getStellarConfig (). networkPassphrase ;
const expectedHomeDomain = input . homeDomain ?. trim () || domain ;
const expectedWebAuthDomain = new URL ( authBase ). hostname . toLowerCase ();
const { clientAccountID } = WebAuth . readChallengeTx (
challengeJson . transaction ,
serverSigningKey ,
networkPassphrase ,
expectedHomeDomain ,
expectedWebAuthDomain
);
if ( clientAccountID !== account ) {
throw new Error ( "SEP-10 challenge account mismatch" );
}
The WebAuth.readChallengeTx function from Stellar SDK performs comprehensive validation including signature verification, domain checks, and expiration validation.
Signing and submission
After verification, the client signs the challenge and submits it:
const tx = TransactionBuilder . fromXDR (
challengeJson . transaction ,
networkPassphrase
);
tx . sign ( keypair );
const signedTx = tx . toEnvelope (). toXDR ( "base64" );
const tokenRes = await fetchWithTimeout (
authBase ,
{
method: "POST" ,
headers: { "Content-Type" : "application/json" , Accept: "application/json" },
body: JSON . stringify ({ transaction: signedTx }),
},
input . timeoutMs ?? DEFAULT_TIMEOUT_MS
);
const tokenJson = await tokenRes . json () as {
token ?: string ;
expires_at ?: string ;
};
Token response
Successful authentication returns a JWT token:
return {
domain ,
webAuthEndpoint: authBase ,
account ,
token: tokenJson . token ,
expiresAt: tokenJson . expires_at ,
};
Response fields
token : JWT token for authenticated requests
expiresAt : ISO 8601 timestamp of token expiration
account : Authenticated Stellar account
domain : Anchor domain
webAuthEndpoint : Authentication endpoint used
Timeout configuration
SEP-10 requests include configurable timeouts to prevent hanging:
const DEFAULT_TIMEOUT_MS = 8000 ;
async function fetchWithTimeout (
url : string ,
init : RequestInit ,
timeoutMs : number
) : Promise < Response > {
const controller = new AbortController ();
const timer = setTimeout (() => controller . abort (), timeoutMs );
try {
return await fetch ( url , { ... init , signal: controller . signal });
} finally {
clearTimeout ( timer );
}
}
Default timeout is 8 seconds but can be customized via the timeoutMs parameter.
Error handling
Comprehensive error handling ensures clear diagnostic messages:
if ( ! challengeRes . ok ) {
const body = await challengeRes . text ();
throw new Error (
`SEP-10 challenge failed ( ${ challengeRes . status } ): ${ body || challengeRes . statusText } `
);
}
if ( ! challengeJson . transaction ) {
throw new Error ( "SEP-10 challenge response missing transaction" );
}
if ( ! tokenRes . ok ) {
const body = await tokenRes . text ();
throw new Error (
`SEP-10 token request failed ( ${ tokenRes . status } ): ${ body || tokenRes . statusText } `
);
}
if ( ! tokenJson . token ) {
throw new Error ( "SEP-10 token response missing token" );
}
export interface Sep10TokenInput {
domain ?: string ;
webAuthEndpoint ?: string ;
serverSigningKey ?: string ;
secretKey : string ;
accountPublicKey ?: string ;
homeDomain ?: string ;
clientDomain ?: string ;
timeoutMs ?: number ;
}
Parameter descriptions
domain : Anchor domain for auto-discovery
webAuthEndpoint : Direct endpoint URL (alternative to domain)
serverSigningKey : Anchor’s public signing key
secretKey : Client’s Stellar secret key (required)
accountPublicKey : Account to authenticate (defaults to secretKey’s public key)
homeDomain : Client’s home domain
clientDomain : Client domain for verification
timeoutMs : Request timeout in milliseconds
You must provide either domain or webAuthEndpoint. If using domain, the stellar.toml will be automatically fetched.
URL normalization
Helper functions ensure consistent URL formatting:
function normalizeBaseUrl ( url : string ) : string {
return url . trim (). replace ( / \/ + $ / , "" );
}
function appendQuery ( url : string , key : string , value ?: string ) : string {
if ( ! value ) return url ;
const separator = url . includes ( "?" ) ? "&" : "?" ;
return ` ${ url }${ separator }${ encodeURIComponent ( key ) } = ${ encodeURIComponent ( value ) } ` ;
}
Network configuration
The implementation uses the configured Stellar network:
services/api/lib/stellar.ts
export function getStellarConfig () {
const popEnv = getPopEnv ();
const defaultPassphrase =
popEnv === "production"
? "Public Global Stellar Network ; September 2015"
: "Test SDF Network ; September 2015" ;
return {
popEnv ,
horizonUrl: process . env . STELLAR_HORIZON_URL ?? defaultHorizonUrl ,
networkPassphrase: process . env . STELLAR_NETWORK_PASSPHRASE ?? defaultPassphrase ,
};
}
Next steps
SEP-24 flows Use SEP-10 tokens for hosted flows
Transaction signing Sign Stellar transactions
Anchor discovery Discover anchor endpoints
Trust evaluation Validate anchor security