Day 51: Authentication Systems: OAuth2, JWT, Refresh Tokens & Secure Storage
Understand the OAuth2 flows actually used by client apps, what a JWT does and does not guarantee, and where tokens should live on web vs mobile.
Study
Concepts
OAuth2: delegated authorization, not authentication by itself
OAuth2 lets a user grant your app limited access to their data on another service (Google, GitHub) WITHOUT sharing their password with your app — the app redirects to the provider, the user approves scoped access, and the provider redirects back with an authorization code your BACKEND exchanges for tokens (the Authorization Code flow, with PKCE for public clients like SPAs/mobile apps that cannot keep a client secret safe). OpenID Connect layers actual AUTHENTICATION (proving who the user is, via an ID token) on top of OAuth2's authorization mechanics — "Sign in with Google" is OIDC, not bare OAuth2.
PKCE (Proof Key for Code Exchange) is now mandatory practice for any client that cannot hold a secret (SPA, mobile app): the client generates a random `code_verifier`, sends its hash (`code_challenge`) in the initial auth request, then must present the original `code_verifier` when exchanging the code for tokens — this prevents an intercepted authorization code from being redeemed by an attacker who does not know the original verifier.
JWT: a signed claim, not automatically secure or revocable
A JWT (JSON Web Token) is a base64url-encoded header + payload + signature — the signature proves the payload has not been tampered with SINCE it was signed by a party holding the secret/private key, but the payload itself is NOT encrypted (anyone can decode and read it, only not forge it) — never put sensitive data in a JWT payload expecting confidentiality. A short-lived ACCESS token (minutes) is sent with each API request; a longer-lived REFRESH token is exchanged for a new access token when it expires, without forcing the user to log in again — this split limits the damage window if an access token leaks, while keeping the login experience persistent.
JWTs are, by design, stateless and hard to revoke early (the server did not "store" the session to invalidate) — real systems mitigate this with short access-token lifetimes, a refresh-token allowlist/blocklist checked server-side on each refresh, or by rotating the refresh token on every use and detecting reuse of an old one as a signal of theft (refresh token rotation).
Where tokens actually live: web vs mobile
On web, `localStorage` is readable by any JS running on the page, making it directly vulnerable to XSS-based token theft (Day 52) — an httpOnly, Secure, SameSite cookie is the safer default for the refresh token specifically, since JS cannot read an httpOnly cookie at all, though this requires your API and web app to share a registrable domain or careful CORS/cookie configuration. On mobile, the platform Keychain (iOS) / Keystore (Android) — accessed via a library like `react-native-keychain` or Expo SecureStore — provides OS-level encrypted storage explicitly designed for credentials, distinct from MMKV/AsyncStorage which are NOT encrypted at rest by default.
See It
Visualizations
Visualization
Authorization Code flow with PKCE (SPA/mobile client)
user logs in and approves scopes
provider verifies the hash matches
Visualization
Where should tokens be stored?
| Web | Mobile (React Native) | |
|---|---|---|
| Access token | In memory (JS variable/state) — never persisted | In memory, or Keychain/Keystore if persistence is needed |
| Refresh token | httpOnly, Secure, SameSite cookie (preferred) | Keychain (iOS) / Keystore (Android) via SecureStore |
| Avoid | localStorage/sessionStorage for tokens (XSS-readable) | Plain AsyncStorage/MMKV for tokens (unencrypted at rest) |
Build It
Code Examples
Refresh-on-401 interceptor, avoiding duplicate refresh calls
let refreshPromise = null;
async function apiFetch(url, options = {}) {
let res = await fetch(url, { ...options, headers: authHeaders() });
if (res.status === 401) {
// De-dupe concurrent refreshes — if 5 requests 401 at once,
// only ONE refresh call is made; the rest await the same promise.
refreshPromise ??= refreshAccessToken().finally(() => { refreshPromise = null; });
const refreshed = await refreshPromise;
if (!refreshed) {
redirectToLogin();
throw new Error('Session expired');
}
res = await fetch(url, { ...options, headers: authHeaders() }); // retry once
}
return res;
}Storing the refresh token securely on mobile with Keychain
import * as Keychain from 'react-native-keychain';
async function saveRefreshToken(token) {
await Keychain.setGenericPassword('refresh_token', token, {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
}
async function getRefreshToken() {
const credentials = await Keychain.getGenericPassword();
return credentials ? credentials.password : null;
}
// Never store this in AsyncStorage or plain MMKV — neither is
// encrypted at rest by default, unlike the platform Keychain/Keystore.Remember
Key Takeaways
- OAuth2 = delegated authorization; OpenID Connect adds actual authentication (an ID token) on top of it.
- PKCE is mandatory for public clients (SPA/mobile) that cannot safely hold a client secret.
- A JWT signature proves integrity, not confidentiality — never put secrets in the payload, it is readable by anyone.
- Short-lived access tokens + longer-lived refresh tokens limit the damage window of a leaked access token.
- Prefer httpOnly cookies for web refresh tokens and Keychain/Keystore for mobile — never localStorage or unencrypted AsyncStorage for tokens.
Do It
Practice
- 1Diagram the full Authorization Code + PKCE flow from memory, labeling exactly which party holds the code_verifier at each step.
- 2Implement the de-duplicated refresh-on-401 interceptor and prove (via a request counter) that 5 concurrent 401s trigger only one refresh call.
- 3Explain, in writing, why storing a JWT in localStorage is vulnerable specifically to XSS, and what storage choice mitigates it on web.