secure-token-storage
Guide for secure-token-storage
Secure Token Storage Guidelines
Best practices for storing JWT tokens securely on client platforms.
Web Clients
Recommended: HttpOnly Cookies
// 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=604800Why HttpOnly?
- JavaScript cannot access the token (prevents XSS theft)
- Automatically sent with requests (no manual handling)
Secureflag ensures HTTPS onlySameSite=Strictprevents 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 uselocalStoragefor 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)
Recommended: Platform Keyring
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)