Most FastAPI JWT tutorials stop at "here's how you issue a token" and never cover the parts that actually break in production: where to store the token on the device, how to refresh it without logging users out at random, and the one claim check that stops a refresh token being replayed as an access token.
This is the full flow I ship in production React Native + FastAPI apps — token issuing and verification on the Python side, secure storage and automatic refresh on the mobile side, and the hardening details that separate a demo from something you can put in front of real users.
How the React Native + FastAPI JWT flow works
The flow has four moving parts:
-
Login. The app POSTs credentials to
/auth/login. FastAPI verifies the password hash and returns two JWTs: a short-lived access token (minutes) and a longer-lived refresh token (days). -
Storage. The app stores both tokens in the platform keychain — iOS Keychain or Android Keystore — through
expo-secure-store. -
Requests. Every API call carries the access token in an
Authorization: Bearerheader, added by an axios request interceptor so no screen ever handles tokens directly. -
Refresh. When the backend answers
401because the access token expired, a response interceptor silently exchanges the refresh token for a new access token and replays the original request.
The result: the user stays logged in for weeks without ever seeing a login screen again, while any individual stolen access token is only useful for a few minutes.
The FastAPI side: hashing and issuing tokens
Use PyJWT (the library FastAPI's own docs use) and the bcrypt package directly for password hashing — it avoids the passlib maintenance drama entirely. Two token types come from the same helper; the type claim matters later so a refresh token can never be replayed as an access token.
# auth.py — token minting and password hashing
import os
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt # PyJWT
SECRET_KEY = os.environ["JWT_SECRET"] # 32+ random bytes, never hardcoded
ALGORITHM = "HS256"
ACCESS_TTL = timedelta(minutes=15)
REFRESH_TTL = timedelta(days=14)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode(), hashed.encode())
def create_token(user_id: str, ttl: timedelta, token_type: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": user_id,
"type": token_type, # "access" or "refresh"
"iat": now,
"exp": now + ttl,
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
The login and refresh endpoints
OAuth2PasswordRequestForm keeps the login endpoint compatible with FastAPI's interactive docs and standard OAuth2 tooling — the form field is called username even when you treat it as an email. The refresh endpoint checks the type claim before issuing a new access token, which is the line most tutorials skip and most audits flag.
# routes/auth.py
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
router = APIRouter(prefix="/auth", tags=["auth"])
class RefreshRequest(BaseModel):
refresh_token: str
@router.post("/login")
async def login(form: OAuth2PasswordRequestForm = Depends()):
user = await get_user_by_email(form.username)
if not user or not verify_password(form.password, user.password_hash):
# Same error for both cases: never reveal which one failed
raise HTTPException(status_code=401, detail="Incorrect email or password")
return {
"access_token": create_token(str(user.id), ACCESS_TTL, "access"),
"refresh_token": create_token(str(user.id), REFRESH_TTL, "refresh"),
"token_type": "bearer",
}
@router.post("/refresh")
async def refresh(body: RefreshRequest):
try:
payload = jwt.decode(body.refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Refresh token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid refresh token")
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Wrong token type")
return {
"access_token": create_token(payload["sub"], ACCESS_TTL, "access"),
"token_type": "bearer",
}
Protecting routes with a get_current_user dependency
Every protected route declares the same dependency. OAuth2PasswordBearer extracts the Bearer token from the Authorization header, and the dependency turns it into a database user or a 401 — route handlers never touch JWT logic.
# deps.py
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=401,
detail="Token expired",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
if payload.get("type") != "access":
raise HTTPException(status_code=401, detail="Wrong token type")
user = await get_user_by_id(payload["sub"])
if user is None:
raise HTTPException(status_code=401, detail="User no longer exists")
return user
# Usage in any router:
# @router.get("/me")
# async def me(user=Depends(get_current_user)):
# return user
The React Native side: secure storage and automatic refresh
Two rules on the mobile side:
-
Tokens go in
expo-secure-store, which wraps the iOS Keychain and Android Keystore — neverAsyncStorage, which is unencrypted and readable on rooted or backed-up devices. -
Refresh must be deduplicated. When five requests fail with
401at the same moment, only one refresh call should go out, with the other four awaiting its result. This is the single most common "randomly logged out" bug I get hired to fix.
// api.ts — axios client with auto-refresh
import axios from "axios";
import * as SecureStore from "expo-secure-store";
const BASE_URL = "https://api.example.com";
export const api = axios.create({ baseURL: BASE_URL });
api.interceptors.request.use(async (config) => {
const token = await SecureStore.getItemAsync("accessToken");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
let refreshing: Promise<string | null> | null = null;
api.interceptors.response.use(undefined, async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
// Dedupe: many parallel 401s share one refresh call
refreshing = refreshing ?? refreshAccessToken().finally(() => {
refreshing = null;
});
const newToken = await refreshing;
if (newToken) {
original.headers.Authorization = `Bearer ${newToken}`;
return api(original);
}
}
return Promise.reject(error);
});
async function refreshAccessToken(): Promise<string | null> {
const refreshToken = await SecureStore.getItemAsync("refreshToken");
if (!refreshToken) return null;
try {
// Plain axios, NOT `api` — avoids an interceptor loop
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, {
refresh_token: refreshToken,
});
await SecureStore.setItemAsync("accessToken", data.access_token);
return data.access_token;
} catch {
await SecureStore.deleteItemAsync("accessToken");
await SecureStore.deleteItemAsync("refreshToken");
return null; // caller lands on the login screen
}
}
export async function login(email: string, password: string) {
const form = new URLSearchParams({ username: email, password });
const { data } = await axios.post(`${BASE_URL}/auth/login`, form.toString(), {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
await SecureStore.setItemAsync("accessToken", data.access_token);
await SecureStore.setItemAsync("refreshToken", data.refresh_token);
}
Production hardening checklist
Before this flow faces real users:
- HTTPS only. A Bearer token on plain HTTP is a credential leak.
- Secret management. Load the signing secret from the environment or a secrets manager, and rotate it if it ever touches a log.
- Short access tokens. Keep them at 15 minutes or less; the refresh token is what gives users long sessions.
- Nothing sensitive in claims. JWTs are base64-encoded, not encrypted — anyone with the token can read the payload.
-
Clean logout. Delete both tokens from SecureStore. If you need server-side revocation (banned users, stolen devices), keep a denylist of refresh-token IDs in Redis and check it in
/auth/refresh. - Multi-service? Use RS256. Then services hold only the public key and the signing key stays in one place.
FAQ
Where should I store JWT tokens in a React Native app?
In the platform keychain via expo-secure-store (or react-native-keychain for bare projects). Never AsyncStorage — it stores plaintext readable from device backups or rooted devices.
How long should access and refresh tokens live?
A common production split is 15 minutes for access tokens and 7–30 days for refresh tokens. High-security apps add refresh-token rotation, where each refresh also issues a new refresh token and invalidates the old one.
PyJWT or python-jose?
PyJWT. FastAPI's official security tutorial uses it, it's actively maintained, and it covers everything a mobile login flow needs.
I'm a freelance React Native + Python engineer and this is the setup from real client work. The full version with the complete conftest.py for testing this flow lives on my site: React Native + Expo JWT Auth with FastAPI. Happy to answer questions on token lifetimes or the refresh dedup in the comments.
Top comments (0)