DEV Community

Cover image for JWT Authentication From Scratch: What It Is, How It Works, and How to Implement It Properly
MEROLINE LIZLENT
MEROLINE LIZLENT

Posted on

JWT Authentication From Scratch: What It Is, How It Works, and How to Implement It Properly

JSON Web Tokens are everywhere. They're the de facto standard for stateless API authentication and they're also one of the most misunderstood and misimplemented security mechanisms in web development.

This article covers JWT from first principles, including common mistakes that create security vulnerabilities, and walks through a complete, production-ready implementation in Python with FastAPI.

What Is a JWT?

A JSON Web Token (JWT) is a compact, URL-safe token that encodes a JSON payload and optionally signs or encrypts it. It's used to transmit verified claims between parties most commonly, "this user is authenticated and has these permissions."

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTcxNjY1MDAwMH0.3rW7TE8iCCiLBOBs6nLBpWxXuV3zqkL9mYpC2HBkd4s
Enter fullscreen mode Exit fullscreen mode

It's three Base64URL-encoded segments joined by dots:

HEADER.PAYLOAD.SIGNATURE
Enter fullscreen mode Exit fullscreen mode

Header

{
  "alg": "HS256",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode

Specifies the algorithm used to sign the token.

Payload (Claims)

{
  "sub": "user_123",
  "name": "Alice",
  "role": "admin",
  "iat": 1716563600,
  "exp": 1716650000
}
Enter fullscreen mode Exit fullscreen mode

Standard claims include:

  • sub — subject (typically a user ID)
  • iat — issued at
  • exp — expiry
  • nbf — not before
  • iss — issuer
  • aud — audience

The payload is NOT encrypted by default. It's only Base64-encoded, which is trivially reversible. Never store sensitive data (passwords, credit card numbers) in a JWT payload.

Signature

HMACSHA256(
  base64url(header) + "." + base64url(payload),
  secret_key
)
Enter fullscreen mode Exit fullscreen mode

The signature is what makes JWTs trustworthy. The server verifies that nobody tampered with the payload by recomputing the signature and comparing it.

How JWT Authentication Works

1. User logs in with credentials (username + password)
2. Server validates credentials, issues a signed JWT
3. Client stores the JWT (usually in memory or localStorage)
4. Client sends JWT in the Authorization header on subsequent requests:
   Authorization: Bearer <token>
5. Server verifies the signature and extracts the claims
6. No database lookup required — the token is self-contained
Enter fullscreen mode Exit fullscreen mode

This is what "stateless" authentication means: the server doesn't need to store session data. All the information is in the token itself.

JWT vs Sessions: When to Use Each

JWT Sessions
Storage Client-side Server-side
Scalability Easy (no shared state) Harder (needs shared session store)
Revocation Hard (tokens are valid until expiry) Easy (delete from store)
Size Larger (header on every request) Small (just a session ID)
Best for Stateless APIs, microservices Traditional web apps with logout

JWTs are great for APIs. For apps where you need instant revocation (logout that actually works), session-based auth or short-lived JWTs + refresh tokens is better.

Implementation: FastAPI + JWT

Dependencies

pip install fastapi uvicorn python-jose[cryptography] passlib[bcrypt] python-multipart
Enter fullscreen mode Exit fullscreen mode
  • python-jose: JWT encoding/decoding
  • passlib: Password hashing with bcrypt

Project Structure

app/
├── main.py
├── auth.py        # JWT logic
├── models.py      # Pydantic models
└── database.py    # Fake DB for demo
Enter fullscreen mode Exit fullscreen mode

auth.py — Core JWT Logic

from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext

# Configuration — store these in environment variables in production!
SECRET_KEY = "your-super-secret-key-change-this-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)

def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + (
        expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    )
    to_encode.update({"exp": expire, "type": "access"})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

def create_refresh_token(data: dict) -> str:
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
    to_encode.update({"exp": expire, "type": "refresh"})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

def decode_token(token: str) -> dict:
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return payload
    except JWTError:
        return None
Enter fullscreen mode Exit fullscreen mode

models.py

from pydantic import BaseModel
from typing import Optional

class UserLogin(BaseModel):
    username: str
    password: str

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

class TokenData(BaseModel):
    user_id: Optional[str] = None
    role: Optional[str] = None
Enter fullscreen mode Exit fullscreen mode

database.py (Mock DB)

from auth import hash_password

# In production, replace with real database queries
FAKE_USERS_DB = {
    "alice": {
        "id": "user_001",
        "username": "alice",
        "hashed_password": hash_password("secret123"),
        "role": "admin",
        "is_active": True,
    },
    "bob": {
        "id": "user_002",
        "username": "bob",
        "hashed_password": hash_password("password456"),
        "role": "user",
        "is_active": True,
    },
}

def get_user(username: str):
    return FAKE_USERS_DB.get(username)
Enter fullscreen mode Exit fullscreen mode

main.py — The Full Application

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from auth import (
    verify_password, create_access_token, create_refresh_token, decode_token
)
from database import get_user
from models import UserLogin, Token

app = FastAPI(title="JWT Auth Demo")
security = HTTPBearer()

# Authentication 
@app.post("/auth/login", response_model=Token)
def login(credentials: UserLogin):
    user = get_user(credentials.username)

    if not user or not verify_password(credentials.password, user["hashed_password"]):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )

    if not user["is_active"]:
        raise HTTPException(status_code=400, detail="Inactive user")

    token_data = {"sub": user["id"], "username": user["username"], "role": user["role"]}

    return Token(
        access_token=create_access_token(token_data),
        refresh_token=create_refresh_token(token_data),
    )

@app.post("/auth/refresh", response_model=Token)
def refresh_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
    payload = decode_token(credentials.credentials)

    if not payload or payload.get("type") != "refresh":
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or expired refresh token",
        )

    token_data = {
        "sub": payload["sub"],
        "username": payload["username"],
        "role": payload["role"],
    }

    return Token(
        access_token=create_access_token(token_data),
        refresh_token=create_refresh_token(token_data),
    )

# Dependency: Get Current User 

def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
    payload = decode_token(credentials.credentials)

    if not payload or payload.get("type") != "access":
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Could not validate credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )

    user = get_user(payload.get("username"))
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    return user

def require_admin(current_user: dict = Depends(get_current_user)):
    if current_user["role"] != "admin":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Admin access required",
        )
    return current_user

# Protected Routes 

@app.get("/me")
def get_profile(current_user: dict = Depends(get_current_user)):
    return {
        "id": current_user["id"],
        "username": current_user["username"],
        "role": current_user["role"],
    }

@app.get("/admin/dashboard")
def admin_dashboard(admin: dict = Depends(require_admin)):
    return {"message": f"Welcome, {admin['username']}. Here's the admin dashboard."}

@app.get("/public")
def public_route():
    return {"message": "This route requires no authentication"}
Enter fullscreen mode Exit fullscreen mode

Testing It

uvicorn main:app --reload
Enter fullscreen mode Exit fullscreen mode

Login:

curl -X POST http://localhost:8000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "alice", "password": "secret123"}'
Enter fullscreen mode Exit fullscreen mode

Use the access token:

curl http://localhost:8000/me \
  -H "Authorization: Bearer <your_access_token>"
Enter fullscreen mode Exit fullscreen mode

Common Security Mistakes and How to Avoid Them

Using a weak or hardcoded secret key

# BAD
SECRET_KEY = "secret"

# GOOD — generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY = os.environ["JWT_SECRET_KEY"]
Enter fullscreen mode Exit fullscreen mode

Using the none algorithm

Some early JWT libraries had a bug where alg: none would skip signature verification. Always explicitly specify allowed algorithms:

jwt.decode(token, SECRET_KEY, algorithms=["HS256"])  # whitelist only
Enter fullscreen mode Exit fullscreen mode

Storing JWTs in localStorage

localStorage is accessible to any JavaScript on the page, making it vulnerable to XSS attacks. Use httpOnly cookies for web apps, or keep tokens in memory for SPAs.

Long-lived access tokens without refresh tokens

If an access token is compromised, you want it to expire quickly. Use short-lived access tokens (15–30 minutes) and longer-lived refresh tokens (7–30 days).

Not validating the exp claim

The python-jose library validates expiry automatically when decoding, but make sure you're not using options={"verify_exp": False} in production.

Putting sensitive data in the payload

The payload is Base64-encoded, not encrypted. Decode any JWT at jwt.io to see this in action. Only store non-sensitive identifiers (user ID, role).

Token Revocation: The Hard Problem

JWTs are stateless by design - there's no built-in revocation mechanism. If a token is stolen, it remains valid until it expires. Solutions:

  1. Short expiry - 15-minute access tokens minimize the damage window
  2. Refresh token rotation - issue a new refresh token on every refresh, invalidate the old one
  3. Denylist - maintain a set of revoked token IDs (the jti claim) in Redis. Defeats some of the stateless benefit but gives you real revocation.
import redis
import uuid

r = redis.Redis()

def create_access_token(data: dict) -> str:
    to_encode = data.copy()
    to_encode.update({
        "jti": str(uuid.uuid4()),  # unique token ID
        "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
    })
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

def is_token_revoked(jti: str) -> bool:
    return r.exists(f"revoked:{jti}")

def revoke_token(jti: str, ttl_seconds: int):
    r.setex(f"revoked:{jti}", ttl_seconds, "1")
Enter fullscreen mode Exit fullscreen mode

Summary

JWT authentication is powerful when implemented correctly. The key things to remember:

  • Never store sensitive data in the payload
  • Use environment variables for secret keys
  • Keep access tokens short-lived, pair with refresh tokens
  • Whitelist algorithms when decoding
  • Understand the revocation tradeoff and design accordingly

The implementation above gives you a solid production foundation. From here, you can add email verification, OAuth2 social login, or 2FA on top of the same JWT infrastructure.

Top comments (0)