SecureStore + Keychain
Where tokens go on iOS + Android.
Where you store the user's JWT matters. AsyncStorage is plaintext on disk — anyone with file-system access reads it. SecureStore (iOS Keychain, Android Keystore) is OS-encrypted. Use it. Here's how.
The right primitive — expo-secure-store
$pnpm add expo-secure-store
Already in the Grit mobile scaffold. Stores values in:
- iOS: Keychain — encrypted by the device's secure enclave, tied to the app's bundle ID
- Android: Keystore + EncryptedSharedPreferences — encrypted by hardware-backed AES, tied to app signature
Both survive app updates. Both wipe on uninstall. Both refuse access from other apps.
The auth helper
import * as SecureStore from 'expo-secure-store'const ACCESS_KEY = 'grit.access_token'const REFRESH_KEY = 'grit.refresh_token'export async function saveTokens(access: string, refresh: string) {await SecureStore.setItemAsync(ACCESS_KEY, access)await SecureStore.setItemAsync(REFRESH_KEY, refresh)}export async function loadTokens() {return {access: await SecureStore.getItemAsync(ACCESS_KEY),refresh: await SecureStore.getItemAsync(REFRESH_KEY),}}export async function clearTokens() {await SecureStore.deleteItemAsync(ACCESS_KEY)await SecureStore.deleteItemAsync(REFRESH_KEY)}
Wiring it into useAuth
export function AuthProvider({ children }) {const [tokens, setTokens] = useState<{ access?: string; refresh?: string }>({})const [isLoading, setLoading] = useState(true)// Restore on app cold startuseEffect(() => {loadTokens().then((stored) => {setTokens(stored)setLoading(false)})}, [])async function login(email: string, password: string) {const res = await api.post('/api/auth/login', { email, password })await saveTokens(res.data.access_token, res.data.refresh_token)setTokens({ access: res.data.access_token, refresh: res.data.refresh_token })}async function logout() {await clearTokens()setTokens({})}return (<AuthContext.Provider value={{ tokens, login, logout, isLoading }}>{children}</AuthContext.Provider>)}
On app start, we read tokens from SecureStore. If they exist, the user is already signed in. No login screen unless they explicitly logged out.
isLoading is false — otherwise the route guard may send the user to /login while the token is still being loaded. Show a splash screen until ready.What NOT to do
// AsyncStorage — plaintext on diskawait AsyncStorage.setItem('token', accessToken) // bad for JWTs// localStorage — there isn't one in React Native// Don't try to use the web localStorage shim
AsyncStorage is fine for non-secret preferences (theme, last-used filter). For tokens, passwords, API keys — SecureStore only.
Encryption-at-rest only matters if the device is unlocked-then-compromised
Once a phone is unlocked, processes that run as you can read your Keychain. The defence model is "another app, or someone with file- system access (e.g., a backup extraction), can't read your tokens." SecureStore covers that. It doesn't protect against malware that runs in your app's sandbox.
Quick check
Try it
Confirm SecureStore is doing its job:
- Sign in on your mobile app
- Add a debug button that calls
SecureStore.getItemAsync('grit.refresh_token')and shows the result in an alert - Confirm the refresh token is there
- Force-kill the app, reopen it. You should still be signed in (the AuthProvider loaded the tokens on cold start).
- Paste a screenshot of the debug alert in
notes.md.
What's next
Last lesson of the chapter — silent refresh on 401. Access tokens expire; refresh tokens save the user from re-login.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub