Overview
Understanding token lifecycle management is critical for building reliable applications. This guide covers everything you need to know about handling JWT tokens, access tokens, and refresh tokens throughout your application’s lifecycle.
Token Types
The API uses three distinct token types, each serving a specific purpose:
JWT Token OAuth Flow Session
Lifetime: 10 minutes
Purpose: OAuth flow coordination
Usage: Only during OAuth authorization
Not for API calls
Access Token API Authentication
Lifetime: 6 hours
Purpose: Authenticate API requests
Usage: All authenticated endpoints
Include in Authorization header
Refresh Token Token Renewal
Lifetime: 7 days
Purpose: Obtain new access tokens
Usage: Token refresh endpoint
Store securely, never expose
Token Lifecycle
JWT Token (OAuth Session)
Purpose
The JWT token is used only during the OAuth authorization flow to maintain session state between OAuth steps.
Not for API Calls: This token is NOT used for authenticating API requests. It’s exclusively for OAuth flow coordination.
Characteristics
Property Value Lifetime 10 minutes Obtained from GET /v1/auth/oauth/authorize/initiateUsed in POST /v1/auth/oauth/authorize (API mode only)Format JWT (JSON Web Token) Renewal Not renewable - restart OAuth flow
Usage Example
// Step 1: Get JWT token
const { token : jwtToken } = await initiateOAuth ();
// Step 3: Use JWT token (API mode only)
await fetch ( '/v1/auth/oauth/authorize' , {
method: 'POST' ,
headers: {
'Authorization' : `Bearer ${ accessTokenFromLogin } ` ,
'Content-Type' : 'application/json'
},
body: JSON . stringify ({
token: jwtToken // JWT token goes in body, not header
})
});
Expiration Handling
const SESSION_TIMEOUT = 10 * 60 * 1000 ; // 10 minutes
class OAuthSession {
private startTime : number ;
private jwtToken : string ;
constructor ( token : string ) {
this . jwtToken = token ;
this . startTime = Date . now ();
}
isExpired () : boolean {
return Date . now () - this . startTime >= SESSION_TIMEOUT ;
}
getToken () : string {
if ( this . isExpired ()) {
throw new Error ( 'OAuth session expired. Please restart authorization flow.' );
}
return this . jwtToken ;
}
getRemainingTime () : number {
return Math . max ( 0 , SESSION_TIMEOUT - ( Date . now () - this . startTime ));
}
}
Access Token (API Authentication)
Purpose
Access tokens authorize your application to make API requests on behalf of the user. Include them in the Authorization: Bearer header to perform user-authorized operations.
Acting on Behalf of Users: Access tokens represent the user’s grant of permission for your application to interact with their account. Use them only for actions the user has authorized.
Characteristics
Property Value Lifetime 6 hours (21,600 seconds) Obtained from POST /v1/auth/oauth/token or POST /v1/auth/loginUsed in Authorization header for all authenticated endpoints Format JWT (JSON Web Token) Renewal Via refresh token
Usage
async function callAPI ( endpoint : string ) {
const accessToken = await getAccessToken ();
const response = await fetch ( `https://api.example.com ${ endpoint } ` , {
headers: {
'Authorization' : `Bearer ${ accessToken } ` ,
'x-client-key' : process . env . CLIENT_KEY !
}
});
return response . json ();
}
// Examples
const user = await callAPI ( '/v1/users/me' );
const wallets = await callAPI ( '/v1/wallets' );
const cards = await callAPI ( '/v1/cards' );
Storage
Web Applications
Mobile Applications
Server-Side
Recommended: HTTP-only Cookies // Server-side: Set HTTP-only cookie
res . cookie ( 'access_token' , token , {
httpOnly: true ,
secure: true ,
sameSite: 'strict' ,
maxAge: 6 * 60 * 60 * 1000 // 6 hours
});
// Client-side: Cookie sent automatically
fetch ( '/api/protected' , {
credentials: 'include'
});
Alternative: Memory Storage (for SPAs) // Store in application state, not localStorage
class TokenManager {
private accessToken : string | null = null ;
setToken ( token : string ) {
this . accessToken = token ;
}
getToken () : string | null {
return this . accessToken ;
}
clearToken () {
this . accessToken = null ;
}
}
Avoid localStorage for sensitive applications: XSS attacks can steal tokens from localStorage. Use HTTP-only cookies or memory storage instead.
Use Platform Secure Storage // React Native: Use expo-secure-store or react-native-keychain
import * as SecureStore from 'expo-secure-store' ;
async function storeToken ( token : string ) {
await SecureStore . setItemAsync ( 'access_token' , token );
}
async function getToken () : Promise < string | null > {
return await SecureStore . getItemAsync ( 'access_token' );
}
async function deleteToken () {
await SecureStore . deleteItemAsync ( 'access_token' );
}
// iOS: Use Keychain
import KeychainSwift
let keychain = KeychainSwift ()
func storeToken ( _ token : String ) {
keychain. set (token, forKey : "access_token" )
}
func getToken () -> String ? {
return keychain. get ( "access_token" )
}
func deleteToken () {
keychain. delete ( "access_token" )
}
// Android: Use EncryptedSharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
val masterKey = MasterKey. Builder (context)
. setKeyScheme (MasterKey.KeyScheme.AES256_GCM)
. build ()
val sharedPreferences = EncryptedSharedPreferences. create (
context,
"secure_prefs" ,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun storeToken (token: String ) {
sharedPreferences. edit (). putString ( "access_token" , token). apply ()
}
fun getToken (): String ? {
return sharedPreferences. getString ( "access_token" , null )
}
Session or Database Storage // Express.js with session
app . post ( '/oauth/callback' , async ( req , res ) => {
const tokens = await exchangeCodeForTokens ( req . query . code );
req . session . accessToken = tokens . access_token ;
req . session . refreshToken = tokens . refresh_token ;
req . session . tokenExpiry = Date . now () + ( tokens . expires_in * 1000 );
res . redirect ( '/dashboard' );
});
// Middleware to check token
async function requireAuth ( req , res , next ) {
if ( Date . now () >= req . session . tokenExpiry ) {
req . session . accessToken = await refreshToken ( req . session . refreshToken );
req . session . tokenExpiry = Date . now () + ( 6 * 60 * 60 * 1000 );
}
next ();
}
# Flask with database storage
@app.route ( '/oauth/callback' )
def oauth_callback ():
code = request.args.get( 'code' )
tokens = exchange_code_for_tokens(code)
# Store in database
db.session.query(UserToken).filter_by( user_id = current_user.id).update({
'access_token' : tokens[ 'access_token' ],
'refresh_token' : tokens[ 'refresh_token' ],
'expires_at' : datetime.now() + timedelta( hours = 6 )
})
db.session.commit()
return redirect( '/dashboard' )
Expiration Detection
interface TokenInfo {
token : string ;
expiresAt : number ;
}
class TokenManager {
private tokenInfo : TokenInfo | null = null ;
setToken ( token : string , expiresIn : number ) {
this . tokenInfo = {
token ,
expiresAt: Date . now () + ( expiresIn * 1000 )
};
}
isExpired () : boolean {
if ( ! this . tokenInfo ) return true ;
// Add 60 second buffer to refresh before actual expiry
return Date . now () >= ( this . tokenInfo . expiresAt - 60000 );
}
async getValidToken () : Promise < string > {
if ( this . isExpired ()) {
await this . refreshToken ();
}
return this . tokenInfo ! . token ;
}
private async refreshToken () {
const refreshToken = await getRefreshToken ();
const tokens = await exchangeRefreshToken ( refreshToken );
this . setToken ( tokens . access_token , tokens . expires_in );
}
}
Best Practice: Refresh tokens 60 seconds before expiry to account for network latency and clock differences.
Refresh Token (Token Renewal)
Purpose
Refresh tokens allow you to obtain new access tokens without requiring the user to re-authenticate, providing a balance between security and user convenience.
Security for Financial Operations: Given that this API handles sensitive financial operations (setting PINs, adding funds, connecting wallets, card management), the refresh token lifetime is intentionally short at 7 days. This aligns with modern banking security standards where US financial institutions now require re-authentication after 15 minutes of inactivity.
With Biometric Authentication: If your application implements FaceID, TouchID, or credential saving, the 7-day refresh token lifetime provides a good balance between security and user experience. Users authenticate frequently with biometrics while maintaining secure access.
Characteristics
Property Value Lifetime 7 days (604,800 seconds) Obtained from POST /v1/auth/oauth/token (authorization_code grant)Used in POST /v1/auth/oauth/token (refresh_token grant)Format Opaque string Single Use No - can be reused until expiry
Step-Up Authentication for Privileged Operations
Critical Security Requirement: Certain privileged operations require step-up authentication regardless of token validity. Users must re-authenticate immediately before performing these actions.
Privileged operations requiring step-up authentication:
Setting or changing PIN codes
Adding or withdrawing funds
Connecting new wallets (custodial or non-custodial)
Card activation or sensitive card operations
Updating security settings
Large transactions above threshold
Implementation:
async function performPrivilegedOperation ( operation : string ) {
// Check if step-up authentication is recent (within 15 minutes)
const lastStepUp = getLastStepUpTimestamp ();
const fifteenMinutes = 15 * 60 * 1000 ;
if ( ! lastStepUp || Date . now () - lastStepUp > fifteenMinutes ) {
// Require fresh authentication
const authenticated = await promptBiometricOrPassword ();
if ( ! authenticated ) {
throw new Error ( 'Step-up authentication required' );
}
setLastStepUpTimestamp ( Date . now ());
}
// Proceed with privileged operation
await executePrivilegedOperation ( operation );
}
Refresh Flow
async function refreshAccessToken () : Promise < TokenResponse > {
const refreshToken = await secureStorage . get ( 'refresh_token' );
const response = await fetch ( 'https://api.example.com/v1/auth/oauth/token' , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'x-client-key' : process . env . CLIENT_KEY ! ,
'x-secret-key' : process . env . SECRET_KEY !
},
body: JSON . stringify ({
grant_type: 'refresh_token' ,
refresh_token: refreshToken
})
});
if ( ! response . ok ) {
if ( response . status === 401 ) {
// Refresh token expired or invalid
throw new Error ( 'REFRESH_TOKEN_EXPIRED' );
}
throw new Error ( 'Token refresh failed' );
}
const tokens = await response . json ();
// Update stored tokens
await secureStorage . set ( 'access_token' , tokens . access_token );
await secureStorage . set ( 'refresh_token' , tokens . refresh_token );
await secureStorage . set ( 'token_expiry' , Date . now () + ( tokens . expires_in * 1000 ));
return tokens ;
}
Response:
{
"access_token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." ,
"token_type" : "Bearer" ,
"expires_in" : 21600 ,
"refresh_token" : "def50200a1b2c3d4e5f6..." ,
"refresh_token_expires_in" : 604800
}
Token Rotation: The API returns a NEW refresh token with each refresh. Always update your stored refresh token.
Automatic Refresh Strategy
Implement automatic token refresh in your API client:
class APIClient {
private isRefreshing = false ;
private refreshPromise : Promise < string > | null = null ;
async request ( endpoint : string , options : RequestInit = {}) {
let token = await this . getValidToken ();
const response = await fetch ( ` ${ this . baseURL }${ endpoint } ` , {
... options ,
headers: {
... options . headers ,
'Authorization' : `Bearer ${ token } ` ,
'x-client-key' : this . clientKey
}
});
// If 401, token might be expired - try refreshing once
if ( response . status === 401 ) {
token = await this . refreshAccessToken ();
// Retry with new token
return fetch ( ` ${ this . baseURL }${ endpoint } ` , {
... options ,
headers: {
... options . headers ,
'Authorization' : `Bearer ${ token } ` ,
'x-client-key' : this . clientKey
}
});
}
return response ;
}
private async getValidToken () : Promise < string > {
const expiry = await secureStorage . get ( 'token_expiry' );
// Refresh if within 60 seconds of expiry
if ( Date . now () >= ( Number ( expiry ) - 60000 )) {
return this . refreshAccessToken ();
}
return secureStorage . get ( 'access_token' );
}
private async refreshAccessToken () : Promise < string > {
// Prevent multiple simultaneous refresh calls
if ( this . isRefreshing ) {
return this . refreshPromise ! ;
}
this . isRefreshing = true ;
this . refreshPromise = this . doRefresh ();
try {
const token = await this . refreshPromise ;
return token ;
} finally {
this . isRefreshing = false ;
this . refreshPromise = null ;
}
}
private async doRefresh () : Promise < string > {
const refreshToken = await secureStorage . get ( 'refresh_token' );
const response = await fetch ( ` ${ this . baseURL } /v1/auth/oauth/token` , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'x-client-key' : this . clientKey ,
'x-secret-key' : this . secretKey
},
body: JSON . stringify ({
grant_type: 'refresh_token' ,
refresh_token: refreshToken
})
});
if ( ! response . ok ) {
// Refresh token expired - need to re-authenticate
await this . handleAuthenticationRequired ();
throw new Error ( 'REFRESH_FAILED' );
}
const tokens = await response . json ();
await secureStorage . set ( 'access_token' , tokens . access_token );
await secureStorage . set ( 'refresh_token' , tokens . refresh_token );
await secureStorage . set ( 'token_expiry' , Date . now () + ( tokens . expires_in * 1000 ));
return tokens . access_token ;
}
private async handleAuthenticationRequired () {
// Clear tokens
await secureStorage . remove ( 'access_token' );
await secureStorage . remove ( 'refresh_token' );
await secureStorage . remove ( 'token_expiry' );
// Redirect to login or show login modal
window . location . href = '/login' ;
}
}
Token Revocation
Understanding when and how to revoke tokens is crucial for security and user experience:
Token Revocation Policies
Login Flow Tokens
OAuth Flow Tokens
Revocation Decision Tree
Standard Login Access Tokens Login access tokens from POST /v1/auth/login are short-lived and irrevocable : Key Points:
Access tokens expire after 6 hours automatically
No explicit revocation needed on login failure
Tokens naturally expire if not used
Cannot be refreshed - user must re-authenticate
When OTP Fails: // OTP failure scenario
const loginResponse = await login ( email , password );
if ( loginResponse . isOtpRequired ) {
await sendOtp ( loginResponse . userId );
// User enters wrong OTP
try {
await loginWithOtp ( email , password , wrongOtp );
} catch ( error ) {
// ✅ You can retry with the same credentials
// No need to revoke anything
const newOtp = await promptUserForNewOtp ();
await loginWithOtp ( email , password , newOtp );
}
}
Best Practice:
Allow multiple OTP retry attempts
No revocation necessary - tokens expire naturally
Clear tokens from local storage on user logout only
OAuth Access and Refresh Tokens OAuth tokens should be explicitly revoked in specific scenarios: When to Revoke OAuth Tokens:
User explicitly logs out
User revokes app access via settings
Security incident detected
App uninstall or data clear
When NOT to Revoke: // ❌ DON'T revoke on OTP failure during OAuth flow
const oauthResponse = await completeOAuthFlow ();
if ( oauthResponse . isOtpRequired ) {
// OTP fails - just retry, don't revoke
try {
await verifyOtp ( otp );
} catch ( error ) {
// ✅ Retry without revoking
await verifyOtp ( newOtp );
}
}
When TO Revoke: // ✅ DO revoke on explicit logout
async function logout () {
await revokeOAuthTokens (); // Explicit revocation
await clearLocalTokens ();
redirectToLogin ();
}
// ✅ DO revoke on security events
async function handleSecurityEvent () {
await revokeOAuthTokens ();
await notifyUser ( 'Your session has been terminated for security reasons' );
}
Use this decision tree to determine if revocation is needed: ┌─ Login Flow?
│
├─ YES → No revocation needed
│ - Tokens expire in 6 hours
│ - OTP failures: just retry
│ - Login failures: tokens never valid
│
└─ NO (OAuth Flow) → Check scenario:
│
├─ User clicking "Logout"? → ✅ REVOKE
├─ User revoking app access? → ✅ REVOKE
├─ Security incident? → ✅ REVOKE
├─ OTP failure? → ❌ DON'T REVOKE (retry)
├─ Successful token issued? → ❌ DON'T REVOKE (use it)
└─ Token refresh? → ❌ DON'T REVOKE (normal flow)
OAuth Token Revocation Implementation
Revoke OAuth tokens when users log out or revoke app access:
async function revokeAccess () {
const accessToken = await secureStorage . get ( 'access_token' );
try {
await fetch ( 'https://api.example.com/v1/auth/oauth/revoke' , {
method: 'DELETE' ,
headers: {
'Authorization' : `Bearer ${ accessToken } ` ,
'x-client-key' : process . env . CLIENT_KEY !
}
});
} finally {
// Always clear local tokens, even if API call fails
await secureStorage . remove ( 'access_token' );
await secureStorage . remove ( 'refresh_token' );
await secureStorage . remove ( 'token_expiry' );
}
}
// Example: Logout with proper revocation
async function logout () {
try {
await revokeAccess ();
} catch ( error ) {
console . error ( 'Revocation failed, but continuing logout:' , error );
} finally {
// Always clear local state, even if revocation fails
await clearAllTokens ();
redirectToLogin ();
}
}
Revocation Response
Success:
{
"success" : true ,
"message" : "Tokens revoked successfully"
}
Failure Scenarios:
// Token already expired or invalid
{
"error" : "invalid_token" ,
"message" : "Token is already invalid"
}
// Token not found
{
"error" : "not_found" ,
"message" : "No active tokens found for this user"
}
Effect of OAuth Revocation: Both access and refresh tokens become invalid immediately. Users must complete the OAuth flow again to regain access.
Best Practices for Token Revocation
Always Clear Local Storage Even if the API revocation call fails, always clear tokens from local storage to prevent unauthorized access. try {
await revokeAccess ();
} finally {
await clearAllTokens (); // Always execute
}
Handle Revocation Failures Gracefully Network issues shouldn’t block logout. Log the error and continue. try {
await revokeAccess ();
} catch ( error ) {
logError ( 'Revocation failed' , error );
// Continue logout anyway
}
Don't Revoke on Retry Scenarios Failed OTP or temporary errors should allow retry without revocation. // ❌ Wrong
if ( otpFailed ) {
await revokeTokens (); // Don't do this
}
// ✅ Correct
if ( otpFailed ) {
await promptForNewOtp (); // Just retry
}
Revoke on Security Events Immediately revoke tokens when security issues are detected. if ( suspiciousActivity ) {
await revokeAccess ();
await notifySecurityTeam ();
await alertUser ();
}
Best Practices
1. Proactive Refresh
Refresh Before Expiry Don’t wait for a 401 error. Refresh tokens 60 seconds before they expire to ensure uninterrupted service. if ( Date . now () >= ( tokenExpiry - 60000 )) {
await refreshAccessToken ();
}
2. Race Condition Prevention
Prevent Concurrent Refreshes Use locks or flags to prevent multiple simultaneous refresh requests. private isRefreshing = false ;
private refreshPromise : Promise < string > | null = null ;
async refreshAccessToken () {
if ( this . isRefreshing ) {
return this . refreshPromise ! ;
}
this . isRefreshing = true ;
// ... refresh logic
}
3. Secure Storage
Use Platform Secure Storage Never store tokens in:
localStorage (web)
AsyncStorage (React Native)
Plain SharedPreferences (Android)
UserDefaults (iOS)
Always use:
HTTP-only cookies (web)
SecureStore (React Native)
Keychain (iOS)
EncryptedSharedPreferences (Android)
OWASP Guidance: Review OWASP Session Management Cheat Sheet and OWASP HTML5 Security Cheat Sheet for comprehensive security guidance.
OWASP Security Best Practices
The following recommendations align with OWASP (Open Web Application Security Project) security standards for token storage and session management.
Web Applications
XSS Vulnerability Risk: A single Cross-Site Scripting (XSS) attack can steal ALL tokens stored in localStorage or sessionStorage. These storage mechanisms are always accessible to JavaScript running on your page.
Cookie-Based Storage (Recommended)
HTTP-only cookies provide the strongest protection against XSS attacks:
// Server-side: Express.js example
app . post ( '/auth/callback' , async ( req , res ) => {
const tokens = await exchangeCodeForTokens ( req . query . code );
res . cookie ( 'access_token' , tokens . access_token , {
httpOnly: true , // Prevents JavaScript access
secure: true , // HTTPS only
sameSite: 'strict' , // CSRF protection
maxAge: 6 * 60 * 60 * 1000 , // 6 hours
domain: '.yourdomain.com' , // Narrow domain scope
path: '/api' // Narrow path scope
});
res . cookie ( 'refresh_token' , tokens . refresh_token , {
httpOnly: true ,
secure: true ,
sameSite: 'strict' ,
maxAge: 7 * 24 * 60 * 60 * 1000 , // 7 days
domain: '.yourdomain.com' ,
path: '/api/auth/refresh' // Even narrower path
});
res . redirect ( '/dashboard' );
});
// Client-side: Cookies sent automatically
fetch ( '/api/users/me' , {
credentials: 'include' // Include cookies in request
});
OWASP Cookie Security Attributes:
Attribute Purpose Required HttpOnlyPrevents JavaScript from reading the cookie (XSS protection) ✅ Yes SecureCookie only sent over HTTPS connections ✅ Yes SameSite=StrictPrevents CSRF attacks by blocking cross-site cookie transmission ✅ Yes DomainLimit cookie to specific domain (narrow scope) ✅ Yes PathLimit cookie to specific path (narrow scope) ✅ Yes Max-AgeCookie expiration time in seconds ✅ Yes
SameSite Options:
Strict: Cookie never sent on cross-site requests (most secure)
Lax: Cookie sent on top-level navigation (GET requests)
None: Cookie sent on all requests (requires Secure flag)
Memory Storage (Alternative for SPAs)
For Single-Page Applications where cookies aren’t feasible, use in-memory storage:
class SecureTokenManager {
private accessToken : string | null = null ;
private refreshToken : string | null = null ;
private tokenExpiry : number | null = null ;
setTokens ( access : string , refresh : string , expiresIn : number ) {
this . accessToken = access ;
this . refreshToken = refresh ;
this . tokenExpiry = Date . now () + ( expiresIn * 1000 );
}
getAccessToken () : string | null {
if ( this . isExpired ()) {
return null ;
}
return this . accessToken ;
}
getRefreshToken () : string | null {
return this . refreshToken ;
}
clearTokens () {
this . accessToken = null ;
this . refreshToken = null ;
this . tokenExpiry = null ;
}
private isExpired () : boolean {
if ( ! this . tokenExpiry ) return true ;
return Date . now () >= ( this . tokenExpiry - 60000 );
}
}
const tokenManager = new SecureTokenManager ();
Memory Storage Limitation: Tokens are lost on page refresh. You’ll need to implement a refresh mechanism or use short-lived sessions. This trade-off prioritizes security over convenience.
localStorage: Last Resort Only
⚠️ Use Only If Absolutely Necessary: localStorage should be avoided for token storage. If you must use it, implement these additional protections:
class LocalStorageTokenManager {
private readonly ACCESS_TOKEN_KEY = 'app_access_token' ;
private readonly REFRESH_TOKEN_KEY = 'app_refresh_token' ;
async setTokens ( access : string , refresh : string ) {
// Encrypt before storing
const encryptedAccess = await this . encrypt ( access );
const encryptedRefresh = await this . encrypt ( refresh );
localStorage . setItem ( this . ACCESS_TOKEN_KEY , encryptedAccess );
localStorage . setItem ( this . REFRESH_TOKEN_KEY , encryptedRefresh );
}
async getAccessToken () : Promise < string | null > {
const encrypted = localStorage . getItem ( this . ACCESS_TOKEN_KEY );
if ( ! encrypted ) return null ;
return await this . decrypt ( encrypted );
}
private async encrypt ( data : string ) : Promise < string > {
const encoder = new TextEncoder ();
const dataBuffer = encoder . encode ( data );
const key = await this . getEncryptionKey ();
const iv = crypto . getRandomValues ( new Uint8Array ( 12 ));
const encrypted = await crypto . subtle . encrypt (
{ name: 'AES-GCM' , iv },
key ,
dataBuffer
);
const combined = new Uint8Array ( iv . length + encrypted . byteLength );
combined . set ( iv );
combined . set ( new Uint8Array ( encrypted ), iv . length );
return btoa ( String . fromCharCode ( ... combined ));
}
private async decrypt ( data : string ) : Promise < string > {
const combined = Uint8Array . from ( atob ( data ), c => c . charCodeAt ( 0 ));
const iv = combined . slice ( 0 , 12 );
const encrypted = combined . slice ( 12 );
const key = await this . getEncryptionKey ();
const decrypted = await crypto . subtle . decrypt (
{ name: 'AES-GCM' , iv },
key ,
encrypted
);
const decoder = new TextDecoder ();
return decoder . decode ( decrypted );
}
private async getEncryptionKey () : Promise < CryptoKey > {
// In production, derive from secure source
// This is a simplified example
const keyMaterial = await crypto . subtle . importKey (
'raw' ,
new TextEncoder (). encode ( 'your-256-bit-secret-key-here!!' ),
'PBKDF2' ,
false ,
[ 'deriveBits' , 'deriveKey' ]
);
return crypto . subtle . deriveKey (
{
name: 'PBKDF2' ,
salt: new TextEncoder (). encode ( 'your-salt' ),
iterations: 100000 ,
hash: 'SHA-256'
},
keyMaterial ,
{ name: 'AES-GCM' , length: 256 },
false ,
[ 'encrypt' , 'decrypt' ]
);
}
clearTokens () {
localStorage . removeItem ( this . ACCESS_TOKEN_KEY );
localStorage . removeItem ( this . REFRESH_TOKEN_KEY );
}
}
OWASP Encryption Standards:
Use AES-256-GCM for symmetric encryption (minimum 128-bit, prefer 256-bit)
Use secure key derivation (PBKDF2, scrypt, or Argon2)
Never hardcode encryption keys
Rotate keys regularly
Mobile Applications
Mobile apps must use platform-native secure storage mechanisms to leverage hardware-backed encryption.
OWASP Mobile Top 10 - M9: Insecure data storage is one of the top mobile security risks. Never store tokens in:
AsyncStorage (React Native)
Plain SharedPreferences (Android)
UserDefaults (iOS)
Application sandboxed directories without encryption
iOS: Keychain Services
import Security
import Foundation
class KeychainTokenManager {
private let service = "com.yourapp.tokens"
func saveToken ( _ token : String , forKey key : String ) throws {
let data = token. data ( using : . utf8 ) !
let query: [ String : Any ] = [
kSecClass as String : kSecClassGenericPassword,
kSecAttrService as String : service,
kSecAttrAccount as String : key,
kSecValueData as String : data,
kSecAttrAccessible as String : kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
// Delete any existing item
SecItemDelete (query as CFDictionary)
let status = SecItemAdd (query as CFDictionary, nil )
guard status == errSecSuccess else {
throw KeychainError. saveFailed
}
}
func getToken ( forKey key : String ) throws -> String ? {
let query: [ String : Any ] = [
kSecClass as String : kSecClassGenericPassword,
kSecAttrService as String : service,
kSecAttrAccount as String : key,
kSecReturnData as String : true ,
kSecMatchLimit as String : kSecMatchLimitOne
]
var result: AnyObject ?
let status = SecItemCopyMatching (query as CFDictionary, & result)
guard status == errSecSuccess,
let data = result as? Data,
let token = String ( data : data, encoding : . utf8 ) else {
return nil
}
return token
}
func deleteToken ( forKey key : String ) {
let query: [ String : Any ] = [
kSecClass as String : kSecClassGenericPassword,
kSecAttrService as String : service,
kSecAttrAccount as String : key
]
SecItemDelete (query as CFDictionary)
}
func deleteAllTokens () {
let query: [ String : Any ] = [
kSecClass as String : kSecClassGenericPassword,
kSecAttrService as String : service
]
SecItemDelete (query as CFDictionary)
}
}
// Usage
let keychainManager = KeychainTokenManager ()
try keychainManager. saveToken (accessToken, forKey : "access_token" )
try keychainManager. saveToken (refreshToken, forKey : "refresh_token" )
iOS Keychain Accessibility Options:
Option Description Use Case kSecAttrAccessibleWhenUnlockedThisDeviceOnlyAccessible only when device unlocked, not backed up ✅ Recommended for tokens kSecAttrAccessibleAfterFirstUnlockThisDeviceOnlyAccessible after first unlock, not backed up Background processing kSecAttrAccessibleWhenUnlockedAccessible when unlocked, backed up to iCloud ❌ Avoid (iCloud backup risk)
Android: Keystore & EncryptedSharedPreferences
Kotlin
React Native (Android)
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import android.content.Context
class SecureTokenStorage (context: Context ) {
private val masterKey = MasterKey. Builder (context)
. setKeyScheme (MasterKey.KeyScheme.AES256_GCM)
. build ()
private val sharedPreferences = EncryptedSharedPreferences. create (
context,
"secure_token_prefs" ,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveTokens (accessToken: String , refreshToken: String ) {
sharedPreferences. edit (). apply {
putString ( "access_token" , accessToken)
putString ( "refresh_token" , refreshToken)
putLong ( "token_saved_at" , System. currentTimeMillis ())
apply ()
}
}
fun getAccessToken (): String ? {
return sharedPreferences. getString ( "access_token" , null )
}
fun getRefreshToken (): String ? {
return sharedPreferences. getString ( "refresh_token" , null )
}
fun deleteAllTokens () {
sharedPreferences. edit (). clear (). apply ()
}
fun isTokenExpired (expirySeconds: Long ): Boolean {
val savedAt = sharedPreferences. getLong ( "token_saved_at" , 0 )
val currentTime = System. currentTimeMillis ()
val elapsed = (currentTime - savedAt) / 1000
return elapsed >= (expirySeconds - 60 )
}
}
// Usage
val storage = SecureTokenStorage (context)
storage. saveTokens (accessToken, refreshToken)
Android Security Features:
Hardware-Backed Keystore: Keys stored in Trusted Execution Environment (TEE) or Secure Element
AES-256-GCM Encryption: Industry-standard authenticated encryption
Key Derivation: EncryptedSharedPreferences handles key generation and rotation
Biometric Binding: Optional binding to device biometrics
Additional Security Measures
Transport Security HTTPS Everywhere
Always use HTTPS for token transmission
Implement certificate pinning for mobile apps
Validate SSL/TLS certificates
Use TLS 1.2 or higher
const API_BASE = 'https://api.example.com' ; // Never HTTP
fetch ( API_BASE + '/endpoint' , {
method: 'POST' ,
headers: {
'Authorization' : `Bearer ${ token } `
}
});
Token Binding Additional Context Validation Bind tokens to additional user context:
Device fingerprint
IP address (with caution)
User-Agent string
Geolocation (for high-risk operations)
const deviceFingerprint = await generateFingerprint ();
await fetch ( '/api/sensitive-operation' , {
headers: {
'Authorization' : `Bearer ${ token } ` ,
'X-Device-Fingerprint' : deviceFingerprint
}
});
Token Rotation Regular Token Rotation
Refresh tokens before expiry
Rotate refresh tokens on each use
Implement token versioning
Revoke old tokens immediately
const tokens = await refreshToken ( oldRefreshToken );
// New refresh token returned
await storage . save ( 'refresh_token' , tokens . refresh_token );
// Old token now invalid
Logging & Monitoring Security Event Logging Log security-relevant events:
Token issuance
Token refresh attempts
Failed authentication
Token revocation
Unusual access patterns
logger . info ( 'Token refreshed' , {
userId: user . id ,
timestamp: new Date (). toISOString (),
ipAddress: req . ip ,
userAgent: req . headers [ 'user-agent' ]
});
OWASP Security Checklist
Use this checklist to ensure your token storage implementation follows OWASP best practices:
Web Applications:
Mobile Applications:
General:
Compliance Note: For applications handling financial data or PHI (Protected Health Information), additional requirements may apply under PCI DSS, HIPAA, or regional regulations (GDPR, CCPA).
4. Error Handling
Handle Refresh Failures Gracefully When refresh fails, clear tokens and redirect to login: if ( response . status === 401 ) {
await clearAllTokens ();
redirectToLogin ();
}
5. Token Cleanup
Clear Tokens on Logout Always clear tokens from storage when users log out: async function logout () {
await revokeAccess (); // API call
await clearAllTokens (); // Local cleanup
redirectToLogin ();
}
Troubleshooting
401 Unauthorized after refresh
Cause: Refresh token expired (7 days) or was revoked.Solution:
Clear all tokens from storage
Redirect user to login
Restart OAuth flow from Step 1
if ( refreshResponse . status === 401 ) {
await clearAllTokens ();
window . location . href = '/login?session_expired=true' ;
}
Token confusion (using wrong token)
Symptom: 401 errors despite having valid tokens.Solution:
JWT Token: Only for OAuth flow (Step 3 body in API mode)
Access Token (login): Only for Step 3 Authorization header (API mode)
Access Token (final): For all subsequent API calls
Refresh Token: Only for token refresh endpoint
// CORRECT usage
// Step 3 (API mode)
headers : { 'Authorization' : `Bearer ${ loginAccessToken } ` },
body : { token : jwtToken }
// All other API calls
headers : { 'Authorization' : `Bearer ${ finalAccessToken } ` }
Multiple refresh requests
Cause: Race condition when multiple API calls detect expiry simultaneously.Solution:
Implement refresh lock/flag
Return same promise for concurrent refresh calls
Queue API calls during refresh
if ( this . isRefreshing ) {
// Wait for existing refresh to complete
await this . refreshPromise ;
return this . getToken ();
}
Cause: Server and client clocks are out of sync.Solution:
Add 60-second buffer before expiry
Use server time in responses when possible
Handle 401s gracefully with automatic retry
const EXPIRY_BUFFER = 60000 ; // 60 seconds
if ( Date . now () >= ( tokenExpiry - EXPIRY_BUFFER )) {
await refreshToken ();
}
Testing Checklist
Functional Testing:
Security Testing:
See the comprehensive OWASP Security Checklist above for platform-specific security requirements including:
Web application cookie security
Mobile platform secure storage verification
Transport security and encryption
Compliance requirements
Next Steps