Skip to main content

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

PropertyValue
Lifetime10 minutes
Obtained fromGET /v1/auth/oauth/authorize/initiate
Used inPOST /v1/auth/oauth/authorize (API mode only)
FormatJWT (JSON Web Token)
RenewalNot 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

PropertyValue
Lifetime6 hours (21,600 seconds)
Obtained fromPOST /v1/auth/oauth/token or POST /v1/auth/login
Used inAuthorization header for all authenticated endpoints
FormatJWT (JSON Web Token)
RenewalVia 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

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.

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

PropertyValue
Lifetime7 days (604,800 seconds)
Obtained fromPOST /v1/auth/oauth/token (authorization_code grant)
Used inPOST /v1/auth/oauth/token (refresh_token grant)
FormatOpaque string
Single UseNo - 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

Standard Login Access TokensLogin 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 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.
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:
AttributePurposeRequired
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:
OptionDescriptionUse Case
kSecAttrAccessibleWhenUnlockedThisDeviceOnlyAccessible only when device unlocked, not backed up✅ Recommended for tokens
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnlyAccessible after first unlock, not backed upBackground processing
kSecAttrAccessibleWhenUnlockedAccessible when unlocked, backed up to iCloud❌ Avoid (iCloud backup risk)

Android: Keystore & EncryptedSharedPreferences

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 ValidationBind 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 LoggingLog 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:
  • Tokens stored in HTTP-only cookies (not localStorage)
  • Secure flag set on all cookies (HTTPS only)
  • SameSite=Strict or Lax set on cookies
  • Narrow Domain and Path cookie attributes
  • Cookies expire with token lifetime
  • HTTPS used for entire application
  • XSS protection headers implemented (X-XSS-Protection, Content-Security-Policy)
  • Tokens never logged or exposed in URLs
Mobile Applications:
  • iOS: Tokens stored in Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly
  • Android: Tokens stored using EncryptedSharedPreferences with AES-256-GCM
  • No tokens stored in AsyncStorage, SharedPreferences, or UserDefaults
  • Certificate pinning implemented
  • Root/jailbreak detection implemented
  • No tokens in application logs or crash reports
  • Tokens cleared on app uninstall or logout
General:
  • Access tokens short-lived (6 hours or less)
  • Refresh tokens limited lifetime (7 days for financial apps)
  • Token refresh implemented with 60-second buffer
  • Automatic logout on token expiry
  • Token revocation on logout
  • No tokens in version control or environment files
  • Encryption keys rotated regularly
  • Security event logging implemented
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

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';
}
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}` }
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:
  • Tokens stored in secure storage
  • Access token automatically refreshes before expiry
  • Refresh token failure triggers re-authentication
  • Concurrent API calls don’t cause multiple refreshes
  • 401 errors trigger token refresh and retry
  • Logout clears all tokens from storage
  • Token expiry has 60-second buffer
  • Revocation invalidates tokens immediately
  • Token confusion doesn’t occur (right token in right place)
  • Clock skew doesn’t cause premature expiry
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