Storing JWT access tokens or refresh tokens in AsyncStorage or UserDefaults writes them as plaintext accessible to any process with filesystem access on a rooted device. CWE-312 (Cleartext Storage of Sensitive Information) and CWE-522 (Insufficiently Protected Credentials) both apply. NIST SP 800-53 IA-5 requires cryptographic protection of authenticators at rest. GDPR Art. 32 mandates appropriate technical measures for personal data — an unprotected refresh token is a credential, and its exposure enables persistent account takeover after the original access token expires.
High because a plaintext refresh token in AsyncStorage on a compromised device grants an attacker persistent, renewable access to the user's account.
Store all tokens exclusively in platform-secure storage. Implement a typed AuthTokenStorage class so there is no ambiguity about which storage API to use at call sites.
import * as SecureStore from 'expo-secure-store';
export class AuthTokenStorage {
private static ACCESS = 'access_token';
private static REFRESH = 'refresh_token';
static save(access: string, refresh: string) {
return Promise.all([
SecureStore.setItemAsync(this.ACCESS, access),
SecureStore.setItemAsync(this.REFRESH, refresh),
]);
}
static getAccess() { return SecureStore.getItemAsync(this.ACCESS); }
static getRefresh() { return SecureStore.getItemAsync(this.REFRESH); }
static clear() {
return Promise.all([
SecureStore.deleteItemAsync(this.ACCESS),
SecureStore.deleteItemAsync(this.REFRESH),
]);
}
}
Grep for AsyncStorage.setItem in your auth module and verify every match stores only non-sensitive data. The secure storage key names must not be hardcoded in a way that reveals their content (prefer opaque identifiers).
ID: mobile-offline-storage.storage-security.secure-token-storage
Severity: high
What to look for: Examine auth token handling. Look for secure storage of JWT tokens, API keys, refresh tokens, or other secrets. Verify tokens are not persisted in AsyncStorage or other plain-text storage.
Pass criteria: Count all token/secret storage operations in the codebase. All authentication tokens and API secrets (at least 1 token type) must be stored in platform-provided secure storage (Keychain, Keystore, or expo-secure-store). No more than 0 tokens should be stored in AsyncStorage or user defaults.
Fail criteria: Tokens or API keys stored in AsyncStorage or user defaults without encryption. Do NOT pass when tokens are stored in secure storage but the secure storage key itself is hardcoded in source code.
Skip (N/A) when: App has no authentication or secrets to store (no auth library found in dependencies).
Detail on fail: "Refresh token stored in AsyncStorage without encryption. Exposed devices could reuse expired sessions."
Remediation: Already covered in encryption-at-rest check, but here's a complete auth storage pattern:
import * as SecureStore from 'expo-secure-store';
export class AuthTokenStorage {
private static readonly ACCESS_TOKEN_KEY = 'accessToken';
private static readonly REFRESH_TOKEN_KEY = 'refreshToken';
static async saveTokens(accessToken: string, refreshToken: string) {
await Promise.all([
SecureStore.setItemAsync(this.ACCESS_TOKEN_KEY, accessToken),
SecureStore.setItemAsync(this.REFRESH_TOKEN_KEY, refreshToken),
]);
}
static async getAccessToken(): Promise<string | null> {
return SecureStore.getItemAsync(this.ACCESS_TOKEN_KEY);
}
static async getRefreshToken(): Promise<string | null> {
return SecureStore.getItemAsync(this.REFRESH_TOKEN_KEY);
}
static async clearTokens() {
try {
await Promise.all([
SecureStore.deleteItemAsync(this.ACCESS_TOKEN_KEY),
SecureStore.deleteItemAsync(this.REFRESH_TOKEN_KEY),
]);
} catch (error) {
console.error('Failed to clear tokens:', error);
}
}
}
// Usage:
await AuthTokenStorage.saveTokens(accessToken, refreshToken);
const token = await AuthTokenStorage.getAccessToken();