Python — FastAPI Development

FastAPI Authentication with JWT and OAuth2

Direct answer

To implement JWT login between a React Native app and a FastAPI backend: expose a /auth/login endpoint that verifies credentials and returns a short-lived access token plus a longer-lived refresh token, store both in the device keychain with expo-secure-store (never AsyncStorage), attach the access token as an Authorization: Bearer header via an axios interceptor, and refresh automatically on 401. Complete working code for both sides is below.

This is the full authentication 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 tutorial from something you can put in front of real users.

Key facts, with sources

  • In the JetBrains Python Developers Survey 2024, which collected responses from more than 30,000 Python developers, FastAPI usage jumped from 29% to 38%, overtaking Django (35%) and Flask (34%) as the most-used Python web framework. (JetBrains Python Developers Survey 2024)
  • The 2025 Stack Overflow Developer Survey shows FastAPI at 14.8% of respondents doing extensive work with it, edging out Flask at 14.4% and Django at 12.6%. (Stack Overflow Developer Survey 2025)
  • FastAPI's official documentation cites independent TechEmpower benchmarks showing FastAPI applications running under Uvicorn as one of the fastest Python frameworks available, ranked only below Starlette and Uvicorn themselves. (FastAPI official documentation)
  • FastAPI surpassed Flask in GitHub stars in December 2025, reaching roughly 88,000 stars compared to Flask's 68,400. (DZone)
  • Industry analysis of FastAPI's 2025 growth reports about 40% year-over-year growth in job mentions and production adoption at companies including Uber, Netflix, and Microsoft. (byteiota)

How the React Native + FastAPI JWT flow works

The flow has four moving parts. First, the app POSTs credentials to /auth/login. FastAPI verifies the password hash and returns two JWTs: an access token that expires in minutes and a refresh token that lives for days. Second, the app stores both tokens in the platform keychain — iOS Keychain or Android Keystore — through expo-secure-store. Third, 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. Fourth, 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 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 are minted by 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 — never AsyncStorage, which is unencrypted and readable on rooted or backed-up devices. And 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.

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: serve everything over HTTPS only — a Bearer token on plain HTTP is a credential leak. Load the signing secret from the environment or a secrets manager, and rotate it if it ever touches a log. Keep access tokens at 15 minutes or less; the refresh token is what gives users long sessions. Put nothing sensitive in JWT claims — they are base64-encoded, not encrypted, and anyone with the token can read them. On 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. Finally, if multiple services need to verify tokens, switch HS256 for RS256 so services hold only the public key and the signing key stays in one place.

When to hire senior help

Bring in senior help when your API needs to handle real concurrency, when you are designing service boundaries and auth for the first time, or when an existing FastAPI codebase mixes sync and async code and latency is degrading. An experienced engineer can usually diagnose event-loop blocking and connection-pool misconfiguration in days, which is far cheaper than re-architecting after launch. If your stack includes React Native + Python + AI, a senior engineer who owns the full product beats coordinating multiple juniors.

Bottom line

Dhairya Senjaliya ships Python — FastAPI Development projects worldwide — book a scoping call to discuss your specific situation.

Common pitfalls to avoid

  • Calling blocking libraries (classic SQLAlchemy sessions, requests, heavy file I/O) inside async def endpoints, which stalls the event loop and erases FastAPI's concurrency advantage
  • Deploying a single Uvicorn process with no process manager or worker scaling, leaving most CPU cores idle under production load
  • Treating the auto-generated OpenAPI docs as a versioning strategy, then breaking mobile and partner clients when response schemas change
  • Running large payloads through deeply nested Pydantic models on every request and response, adding serialization latency that shows up only at scale

Frequently asked questions

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) — it encrypts values with the iOS Keychain and Android Keystore. Never use AsyncStorage for tokens: it stores plaintext that can be read 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. Short access tokens cap the damage of a leaked token; the refresh token keeps users logged in. High-security apps (fintech, health) often add refresh-token rotation, where each refresh call also issues a new refresh token and invalidates the old one.

Should I use PyJWT or python-jose with FastAPI?

PyJWT. FastAPI's official security tutorial uses PyJWT, it is actively maintained, and it covers everything a mobile login flow needs. python-jose pulls in a larger dependency surface and has had long gaps between releases.

How do I send OAuth2PasswordRequestForm data from React Native?

As application/x-www-form-urlencoded, not JSON — OAuth2PasswordRequestForm reads form fields named username and password. In React Native, build the body with URLSearchParams and set the Content-Type header explicitly (see the login() function above).

Is FastAPI mature enough for production?

Yes. It was the most-used Python web framework in the JetBrains 2024 survey at 38%, and companies including Uber, Netflix, and Microsoft run it in production. The ecosystem for auth, ORMs, and testing is now well established.

How much faster is FastAPI than Flask or Django really?

Independent TechEmpower benchmarks place FastAPI among the fastest Python frameworks, and published comparisons show several times Flask's throughput on I/O-bound endpoints. For CPU-bound work or database-bottlenecked apps, the framework choice matters far less than query and infrastructure design.

Should we pick FastAPI or Django for a new SaaS backend?

FastAPI suits API-first products, microservices, and ML model serving because of async support and automatic OpenAPI docs. Django ships batteries included (admin, ORM, auth) and is often faster to launch a conventional CRUD product. Many teams run both, per the JetBrains finding that a third of Django developers also use Flask or FastAPI.

Bottom line: Dhairya Senjaliya ships Python — FastAPI Development projects worldwide. Book a scoping call at https://dhairyasenjaliya.com/#book-call.

Sources

Related guides

Keep up with new guides

New deep-dive guides on React Native, Python, and AI ship regularly. Subscribe via RSS or follow on LinkedIn.

Want help implementing this?

30-minute scoping call · Clear milestones · Senior engineer ownership