DEV Community

Dhairya Senjaliya
Dhairya Senjaliya

Posted on • Originally published at dhairyasenjaliya.com

React Native + Expo JWT Auth with FastAPI — A Complete Working Example

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:

  1. 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).
  2. Storage. The app stores both tokens in the platform keychain — iOS Keychain or Android Keystore — through expo-secure-store.
  3. Requests. Every API call carries the access token in an Authorization: Bearer header, added by an axios request interceptor so no screen ever handles tokens directly.
  4. Refresh. When the backend answers 401 because 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)
Enter fullscreen mode Exit fullscreen mode

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",
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 — never AsyncStorage, which is unencrypted and readable on rooted or backed-up devices.
  • Refresh must be deduplicated. When five requests fail with 401 at 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);
}
Enter fullscreen mode Exit fullscreen mode

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)