DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Authentication in Modern Full-Stack Apps: JWT, Sessions, and OAuth 2.0

I've implemented authentication in CitizenApp across multiple iterations, and I'm going to be blunt: most tutorials get this wrong. They'll show you a basic JWT implementation or a simple session setup, but they won't show you the real production patterns that keep your application secure without shooting yourself in the foot six months later.

Let me walk you through what actually works at scale, starting with the core decision that shapes everything else.

JWT vs Sessions: The Actual Trade-offs

Everyone treats this like a binary choice, but it's not. Here's what I learned the hard way:

JWT is stateless, which is its greatest strength and weakness. The appeal is obvious—no server-side session store, scales horizontally without coordination, works beautifully for distributed systems. But statelessness means you can't revoke a token until it expires. This burned me when a user in CitizenApp got compromised and I couldn't immediately invalidate their access. They had to wait 15 minutes for their token to expire.

Sessions are stateful but revocable. If someone gets hacked, you revoke the session immediately. The trade-off is you need a persistent store (Redis, database) and you're hitting it on every request to verify the session exists. At scale, this is fine—Redis is fast—but it's architectural overhead.

I prefer hybrid authentication: JWT for the access token (short-lived, 15 minutes), and a session-like refresh token stored server-side that's long-lived (7 days). This gives you revocation capability while keeping the horizontal scalability of JWTs.

Here's why: an attacker with a stolen short-lived JWT can't do much damage. A stolen refresh token? That's bad, but you can revoke it immediately. And you get the best of both worlds.

The FastAPI Implementation

Let me show you the actual pattern I use in CitizenApp. First, the token schemas:

from datetime import datetime, timedelta
from typing import Optional
from pydantic import BaseModel

class TokenResponse(BaseModel):
    access_token: str
    refresh_token: str
    token_type: str = "bearer"

class TokenPayload(BaseModel):
    sub: int  # user_id
    exp: datetime
    iat: datetime
    type: str  # "access" or "refresh"
Enter fullscreen mode Exit fullscreen mode

Now the crucial part—generating both tokens with different expiration times:

from jose import JWTError, jwt
import os

SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7

def create_access_token(user_id: int) -> str:
    now = datetime.utcnow()
    expires = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    payload = {
        "sub": user_id,
        "type": "access",
        "iat": now.isoformat(),
        "exp": expires.isoformat(),
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

def create_refresh_token(user_id: int) -> str:
    now = datetime.utcnow()
    expires = now + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
    payload = {
        "sub": user_id,
        "type": "refresh",
        "iat": now.isoformat(),
        "exp": expires.isoformat(),
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
Enter fullscreen mode Exit fullscreen mode

The refresh token gets stored server-side. I use a simple RefreshToken table:

from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

class RefreshTokenModel(Base):
    __tablename__ = "refresh_tokens"

    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    token_hash = Column(String(255), unique=True, nullable=False)  # hashed JWT
    expires_at = Column(DateTime, nullable=False)
    is_revoked = Column(Boolean, default=False)
    created_at = Column(DateTime, default=datetime.utcnow)
Enter fullscreen mode Exit fullscreen mode

Why store it hashed? If your database gets compromised, tokens aren't immediately usable. Use SHA-256:

import hashlib

def hash_token(token: str) -> str:
    return hashlib.sha256(token.encode()).hexdigest()

async def store_refresh_token(db: Session, user_id: int, token: str):
    token_hash = hash_token(token)
    expires_at = datetime.utcnow() + timedelta(days=7)

    db_token = RefreshTokenModel(
        user_id=user_id,
        token_hash=token_hash,
        expires_at=expires_at
    )
    db.add(db_token)
    db.commit()
Enter fullscreen mode Exit fullscreen mode

React 19 Implementation with Secure Storage

On the frontend, here's where most implementations fail: they store tokens in localStorage, which is vulnerable to XSS. I store the access token in memory, and the refresh token in an httpOnly cookie (set by FastAPI during login).

// hooks/useAuth.ts
import { useState, useCallback } from 'react';

interface AuthState {
  user: User | null;
  accessToken: string | null;
  isLoading: boolean;
}

export function useAuth() {
  const [authState, setAuthState] = useState<AuthState>({
    user: null,
    accessToken: null,
    isLoading: false,
  });

  const login = useCallback(async (email: string, password: string) => {
    setAuthState(prev => ({ ...prev, isLoading: true }));

    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        credentials: 'include', // Send cookies
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });

      if (!response.ok) throw new Error('Login failed');

      const data = await response.json();
      setAuthState({
        user: data.user,
        accessToken: data.access_token,
        isLoading: false,
      });
    } catch (error) {
      setAuthState(prev => ({ ...prev, isLoading: false }));
      throw error;
    }
  }, []);

  return { ...authState, login };
}
Enter fullscreen mode Exit fullscreen mode

The FastAPI login endpoint sets the refresh token as an httpOnly cookie:

from fastapi import Response

@router.post("/auth/login")
async def login(credentials: LoginSchema, response: Response, db: Session = Depends(get_db)):
    user = db.query(User).filter(User.email == credentials.email).first()

    if not user or not verify_password(credentials.password, user.password_hash):
        raise HTTPException(status_code=401, detail="Invalid credentials")

    access_token = create_access_token(user.id)
    refresh_token = create_refresh_token(user.id)

    await store_refresh_token(db, user.id, refresh_token)

    # httpOnly, Secure, SameSite—the holy trinity
    response.set_cookie(
        key="refresh_token",
        value=refresh_token,
        httponly=True,
        secure=True,  # HTTPS only
        samesite="strict",
        max_age=7 * 24 * 60 * 60,
    )

    return {
        "user": user.dict(),
        "access_token": access_token,
        "token_type": "bearer",
    }
Enter fullscreen mode Exit fullscreen mode

Refresh Token Rotation

The real security magic happens when your access token expires. You call the refresh endpoint:

@router.post("/auth/refresh")
async def refresh(request: Request, db: Session = Depends(get_db)):
    refresh_token = request.cookies.get("refresh_token")

    if not refresh_token:
        raise HTTPException(status_code=401, detail="No refresh token")

    try:
        payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
        user_id = payload["sub"]
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

    # Check if token exists and isn't revoked
    token_hash = hash_token(refresh_token)
    db_token = db.query(RefreshTokenModel).filter(
        RefreshTokenModel.token_hash == token_hash,
        RefreshTokenModel.is_revoked == False,
    ).first()

    if not db_token:
        raise HTTPException(status_code=401, detail="Token revoked or expired")

    # Issue new access token AND rotate refresh token
    new_access_token = create_access_token(user_id)
    new_refresh_token = create_refresh_token(user_id)

    # Revoke old refresh token
    db_token.is_revoked = True
    db.commit()

    # Store new one
    await store_refresh_token(db, user_id, new_refresh_token)

    response = JSONResponse({"access_token": new_access_token})
    response.set_cookie(
        key="refresh_token",
        value=new_refresh_token,
        httponly=True,
        secure=True,
        samesite="strict",
        max_age=7 * 24 * 60 * 60,
    )
    return response
Enter fullscreen mode Exit fullscreen mode

Gotcha: CSRF When Using Cookies

Here's what burned me: if you're storing the refresh token in cookies, you're vulnerable to CSRF attacks unless you implement CSRF protection. React doesn't automatically add CSRF tokens—you have to do it yourself.

When you make state-changing requests (POST, PUT, DELETE), extract the CSRF token from a meta tag or cookie and send it:


typescript
// Middleware to add CSRF token
Enter fullscreen mode Exit fullscreen mode

Top comments (0)