The useWallet() hook is built on three layers:
┌─────────────────────────────────────────────────────────────┐
│ useWallet Hook │
│ (Exposes clean interface: isConnected, publicKey, jwt...) │
├─────────────────────────────────────────────────────────────┤
│ SEP-10 Authentication Functions │
│ (performSEP10Auth, fetchChallenge, submitChallenge) │
├─────────────────────────────────────────────────────────────┤
│ WalletProvider Context │
│ (Freighter/WalletConnect integration) │
└─────────────────────────────────────────────────────────────┘
App
├── WalletProvider
│ └── Your Components
│ └── useWallet Hook
User clicks "Connect"
↓
useWallet.connect()
↓
context.connect() [Open provider modal]
↓
User selects provider (Freighter/WalletConnect)
↓
Provider connection established
↓
context.address is set
↓
performSEP10Auth(address)
↓
├─ fetchSEP10Challenge(address)
│ └─ GET /api/auth/challenge?account={address}
│ └─ Returns: { challenge: "AAAAAgAAAAA..." }
│
├─ signTransaction(challenge)
│ └─ Wallet signs the challenge
│ └─ Returns: signed challenge XDR
│
└─ submitSEP10Challenge(address, signedChallenge)
└─ POST /api/auth/verify
└─ Returns: { token: "eyJhbGc..." }
↓
jwtToken = received token (stored in module scope)
↓
hook.jwt is now available
↓
✓ Connection complete
User clicks "Disconnect"
↓
useWallet.disconnect()
↓
jwtToken = null [clear from memory]
↓
context.disconnect()
↓
Router pushes to home page
↓
✓ Disconnected & JWT cleared
1. Initial State
└─ jwtToken = null (module variable)
2. After connect() succeeds
└─ jwtToken = "eyJhbGc..."
3. Available to use
└─ jwt property exposes the token
4. On disconnect()
└─ jwtToken = null (cleared)
5. On page reload
└─ jwtToken = null (not persisted)
6. Browser closes
└─ jwtToken = null (garbage collected)
The JWT is stored as a module-level variable rather than in component state:
// ✓ Module-level (persists across hook calls)
let jwtToken: string | null = null;
// This allows multiple instances of useWallet() to share the same token
// while keeping it private and out of localStorage- Secure: Never persisted to localStorage
- Shared: All hook instances access the same token
- Simple: No state management complexity
- Ephemeral: Lost on page refresh
- Clean: Automatically garbage collected on browser close
- Not persisted: Requires re-auth on page refresh
- Session-only: No cross-tab sharing
- Memory-based: Lost if application crashes
This is by design - SEP-10 is meant for session-based auth, not persistent login.
The challenge is a Stellar transaction with:
- Source: Server public key
- Operation: ManageData with account address
- Expiration: 15 minutes (configurable)
- Timebounds: Prevents replay attacks
ManageData Operation:
├─ Name: "SEP10 Challenge"
├─ Value: 64 random bytes
└─ Source: User's public key
- Signature Check: Verify transaction is signed by user
- Source Check: Verify the ManageData operation source is the user
- Expiration Check: Verify transaction hasn't expired
- Challenge Check: Verify this is a valid challenge we issued
{
"account": "GBZXN7PIRZGNMHGA7MUSC23TFSQ55TWREN3QQR5UELWXONE4O36XL7QP",
"iat": 1689971600,
"exp": 1690058000
}account: User's public keyiat: Issued at (Unix timestamp)exp: Expires (Unix timestamp) - typically 24 hours
- Hook created with all required exports
- SEP-10 functions implemented (challenge, verify, sign)
- JWT stored in memory only
- Error handling comprehensive
- TypeScript types defined
- Tests written (unit tests)
- API endpoints documented
-
Backend Setup
- Implement
/api/auth/challengeendpoint - Implement
/api/auth/verifyendpoint - Set
SEP10_SERVER_SECRET_KEYenvironment variable - Set
JWT_SECRET_KEYenvironment variable - Implement JWT verification middleware for protected routes
- Implement
-
Frontend Setup
- Wrap app in WalletProvider
- Use useWallet hook in components
- Add JWT to API request headers
-
Security
- Validate JWT on backend for protected routes
- Implement token refresh if needed
- Add CORS configuration
- Use HTTPS only
-
Testing
- Run unit tests:
npm test useWallet - Manual E2E testing with Freighter
- Test error scenarios
- Run unit tests:
The challenge endpoint should:
- Validate the account public key format
- Create a SEP-10 challenge transaction
- Return it as XDR (transaction envelope)
Key considerations:
- Use same network passphrase as client
- Set reasonable expiration (typically 15 minutes)
- Include random data in ManageData operation
- Sign with server's private key
The verify endpoint should:
- Parse the signed transaction
- Validate the signature
- Ensure source of ManageData operation matches account
- Check expiration hasn't passed
- Generate and return JWT
Key considerations:
- Verify signature cryptographically
- Check that this is the challenge we issued (optional but recommended)
- Prevent replay attacks (optional but recommended)
- Use strong JWT signing key
- Set appropriate expiration on JWT (24h is typical)
If challenge fetch fails:
- Hook catches error
authAttemptedRef.current = false(allows retry)- Error bubbles to caller
- User can retry by calling
connect()again
If wallet rejects signing:
performSEP10Auththrows errorjwtTokenremains null- User can retry
If server rejects signed transaction:
- Hook catches error
jwtTokenremains null- Error information provided to caller
const { isConnected, publicKey } = useWallet();
console.log(`Connected: ${isConnected}, Address: ${publicKey}`);const { jwt } = useWallet();
console.log(`JWT Available: ${jwt ? "yes" : "no"}`);
// Decode JWT (client-side only, for debugging)
if (jwt) {
const [header, payload, sig] = jwt.split('.');
const decoded = JSON.parse(atob(payload));
console.log(`JWT Payload:`, decoded);
}// Add logging to useWallet.ts for detailed flow tracking
console.log('[SEP-10] Fetching challenge for', publicKey);
console.log('[SEP-10] Challenge received, signing...');
console.log('[SEP-10] Submitting signed challenge...');
console.log('[SEP-10] JWT received:', jwt ? 'yes' : 'no');-
"Must be used within WalletProvider"
- Ensure WalletProvider wraps your components
- Check component hierarchy
-
SEP-10 challenge fails with 404
- Verify
/api/auth/challengeendpoint exists - Check API routing configuration
- Verify
-
JWT is always null
- Check SEP-10 verify endpoint response
- Verify JWT_SECRET_KEY is set
- Check server logs for verification errors
-
Connection works but JWT doesn't persist
- This is expected! JWT is memory-only
- JWT will be cleared on page refresh
- Implement refresh token flow if persistence needed
- SEP-10 flow adds ~2-3 seconds to connection time (challenge + signing + verify)
- JWT is lightweight and cached in memory
- No network calls needed to access JWT (it's already in memory)
- Disconnect is instant (just clears memory reference)
- JWT Secret: Use strong, unique secret for signing JWTs
- HTTPS Only: Never transmit JWTs over HTTP
- Token Storage: Already secure (memory only)
- Token Expiration: Set reasonable expiration (24h recommended)
- Replay Protection: Challenge should be single-use
- Signature Verification: Always verify signatures on backend
Potential improvements:
- Implement token refresh flow for longer sessions
- Add token persistence with security key (IndexedDB)
- Support for multiple wallet providers simultaneously
- Add CEP-46 (Stellar Protocol 21) support
- Implement claim-based scopes for fine-grained permissions
- Add rate limiting to prevent brute force attacks
- Cache challenges temporarily to prevent DoS