secure-token-storage

Guide for secure-token-storage

Secure Token Storage Guidelines

Best practices for storing JWT tokens securely on client platforms.

Web Clients

// Server sets cookie on login response
Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900

// Refresh token (longer lived)
Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Path=/api/auth/refresh; Max-Age=604800
Why HttpOnly?
  • JavaScript cannot access the token (prevents XSS theft)
  • Automatically sent with requests (no manual handling)
  • Secure flag ensures HTTPS only
  • SameSite=Strict prevents CSRF

Alternative: Memory + Session Storage

If cookies aren't suitable:
// Store access token in memory (lost on page refresh)
let accessToken = null;

// Store refresh token in sessionStorage (cleared on tab close)
sessionStorage.setItem('refresh_token', token);
WARNING
Never use localStorage for tokens (persists indefinitely, vulnerable to XSS).

Mobile Clients (iOS/Android)

iOS: Keychain Services

import Security

func storeToken(_ token: String, key: String) {
    let data = token.data(using: .utf8)!
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: key,
        kSecValueData as String: data,
        kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
    ]
    SecItemAdd(query as CFDictionary, nil)
}

Android: EncryptedSharedPreferences

val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val sharedPrefs = EncryptedSharedPreferences.create(
    context,
    "secure_prefs",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

sharedPrefs.edit().putString("access_token", token).apply()

Desktop Game Client (Rust/Bevy)

use keyring::Entry;

fn store_token(token: &str) -> Result<(), keyring::Error> {
    let entry = Entry::new("legends_of_hastinapur", "access_token")?;
    entry.set_password(token)?;
    Ok(())
}

fn get_token() -> Option<String> {
    let entry = Entry::new("legends_of_hastinapur", "access_token").ok()?;
    entry.get_password().ok()
}

Fallback: Encrypted File

If keyring unavailable, use encrypted file with machine-specific key:
// Key derived from hardware ID + user ID
let key = derive_key(&get_machine_id(), &get_user_id());
let encrypted = encrypt_aes_gcm(token.as_bytes(), &key);
std::fs::write(".loh_token", encrypted)?;

Token Refresh Strategy

┌─────────────────────────────────────────────────────────────┐
│                      Token Lifecycle                        │
├─────────────────────────────────────────────────────────────┤
│  Access Token: 15 minutes                                   │
│  Refresh Token: 7 days (rotated on use)                     │
│                                                             │
│  Refresh when:                                              │
│  - Access token expires (401 response)                      │
│  - Proactively at T-2 minutes before expiry                 │
│                                                             │
│  On refresh failure:                                        │
│  - Clear all tokens                                         │
│  - Redirect to login                                        │
└─────────────────────────────────────────────────────────────┘

Security Checklist

  • Access tokens stored in memory or HttpOnly cookies
  • Refresh tokens in secure storage (Keychain/Keystore/EncryptedPrefs)
  • Never log tokens to console or crash reports
  • Clear tokens on logout
  • Handle 401 responses by attempting refresh once
  • Implement token expiry buffer (refresh before actual expiry)