Business Apps

Add User Authentication to a Python AI App

This guide shows you how to add real user authentication to a FastAPI AI app in about twenty minutes: hashed passwords, signed login tokens, and a protected route that knows who is calling. Once an AI endpoint costs you money per request, you need to know that the person hitting it is a real, logged-in user, not an anonymous stranger burning your OpenAI budget. Authentication is the gate that answers "who is this?" before any model call runs.

We will build four small pieces: a way to store users with hashed passwords, a /login route that hands back a token, a token check that runs on every protected request, and a /me route that reads the current user. This sits under SaaS MVP with Python and AI, the main guide for turning an AI feature into a billable product, and pairs naturally with Add Stripe Billing to an AI SaaS with Python and Rate-Limit AI API Calls in a SaaS with Python.

Prerequisites

You need Python 3.10 or newer and a working FastAPI app. If you have followed SaaS MVP with Python and AI you already have most of this. This guide only adds the auth layer, so the only new pieces are the password and token libraries.

python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install "fastapi>=0.110" "uvicorn[standard]" "passlib[bcrypt]" "python-jose[cryptography]" "python-multipart"

A quick note on the libraries: passlib is the password-hashing toolkit (with bcrypt as the actual hashing algorithm), python-jose signs and verifies JWTs, and python-multipart lets FastAPI read the form fields that the standard login flow uses.

Step 1: Store a JWT secret in .env

A JWT is only as safe as the secret key used to sign it. Anyone who knows that key can forge a token for any user, so it must never live in your source code. Generate a long random string and put it in a .env file.

python -c "import secrets; print(secrets.token_urlsafe(32))"

Paste the output into .env:

JWT_SECRET=paste-your-long-random-string-here
JWT_ALGORITHM=HS256
ACCESS_TOKEN_MINUTES=30

Add .env to your .gitignore right now so the secret never reaches Git:

echo ".env" >> .gitignore

Load these values at startup with python-dotenv (install it with pip install python-dotenv):

import os
from dotenv import load_dotenv

load_dotenv()

JWT_SECRET = os.environ["JWT_SECRET"]
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
ACCESS_TOKEN_MINUTES = int(os.getenv("ACCESS_TOKEN_MINUTES", "30"))

Using os.environ["JWT_SECRET"] (not .get) means the app refuses to start if the secret is missing, which is exactly what you want.

Step 2: Hash and verify passwords

Never store a raw password. Store a one-way bcrypt hash, which cannot be reversed back into the original text. At login you hash the submitted password and compare hashes. passlib gives you both operations through a CryptContext.

from passlib.context import CryptContext

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


def hash_password(plain: str) -> str:
    """Turn a raw password into a bcrypt hash safe to store."""
    return pwd_context.hash(plain)


def verify_password(plain: str, hashed: str) -> bool:
    """Check a submitted password against the stored hash."""
    return pwd_context.verify(plain, hashed)

For this guide we keep users in a plain dictionary so you can run it without a database. Swap this for a real table once it works. Notice the stored value is the hash, never the password.

# A stand-in "database". Replace with Postgres or SQLite later.
fake_users: dict[str, dict] = {}


def create_user(email: str, password: str) -> dict:
    if email in fake_users:
        raise ValueError("User already exists")
    user = {"id": len(fake_users) + 1, "email": email,
            "hashed_password": hash_password(password)}
    fake_users[email] = user
    return user


# Seed one user so you have something to log in with.
create_user("founder@example.com", "supersecret123")

Step 3: Issue a JWT access token on login

When a user proves their password, you hand them a signed token. The token's sub (subject) claim holds the user id, and exp (expiry) tells the server when it stops being valid. python-jose encodes and signs it with your secret.

from datetime import datetime, timedelta, timezone
from jose import jwt


def create_access_token(user_id: int) -> str:
    expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_MINUTES)
    payload = {"sub": str(user_id), "exp": expire}
    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)

Now wire up the /login route. FastAPI's OAuth2PasswordRequestForm reads the standard username and password form fields, so tools and the built-in docs page work out of the box. We treat username as the email.

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm

app = FastAPI()


@app.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends()):
    user = fake_users.get(form.username)
    if not user or not verify_password(form.password, user["hashed_password"]):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect email or password",
        )
    token = create_access_token(user["id"])
    return {"access_token": token, "token_type": "bearer"}

Returning the same "Incorrect email or password" message whether the email or the password was wrong is deliberate. It stops an attacker from learning which emails are registered.

Step 4: Protect a route and read the current user

The last piece is a dependency that runs before any protected route. It pulls the token out of the Authorization: Bearer ... header, decodes it, and turns the user id back into a user. If the token is missing, expired, or forged, it raises a 401 and the route never runs.

from fastapi.security import OAuth2PasswordBearer
from jose import JWTError

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")


def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
    credentials_error = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
        user_id = int(payload["sub"])
    except (JWTError, KeyError, ValueError):
        raise credentials_error

    user = next((u for u in fake_users.values() if u["id"] == user_id), None)
    if user is None:
        raise credentials_error
    return user

Any route that adds current_user = Depends(get_current_user) is now locked. Here is a public route, a /me route that reads the logged-in user, and a protected AI endpoint that only runs for authenticated callers.

@app.get("/")
def public_home():
    return {"message": "Anyone can see this."}


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


@app.post("/generate")
def generate(prompt: str, current_user: dict = Depends(get_current_user)):
    # current_user is guaranteed here, so you know who to bill.
    return {"user": current_user["email"], "result": f"AI output for: {prompt}"}

Run it with uvicorn main:app --reload, open http://127.0.0.1:8000/docs, click Authorize, and log in with founder@example.com / supersecret123. The /me and /generate routes now work; calling them without a token returns 401.

Key parameters quick reference

ParameterTypeDefaultEffect
ACCESS_TOKEN_MINUTESint30How long a token stays valid before the user must log in again.
JWT_ALGORITHMstr"HS256"Signing algorithm; HS256 uses one shared secret and suits a single server.
schemes (CryptContext)list["bcrypt"]Hashing algorithm for passwords; bcrypt is the safe default.
tokenUrl (OAuth2PasswordBearer)str"login"The path FastAPI's docs page posts credentials to when you click Authorize.

Troubleshooting

  1. 401 Could not validate credentials right after login. The token expired or the secret changed. Check that ACCESS_TOKEN_MINUTES is not tiny and that JWT_SECRET is identical between issuing and decoding. Restarting with a different secret invalidates every existing token.
  2. AttributeError: module 'bcrypt' has no attribute '__about__'. This comes from a version mismatch between passlib and a newer bcrypt. Pin them: pip install "passlib[bcrypt]" "bcrypt<4.1".
  3. Form data requires "python-multipart". The login route reads form fields, so install the package: pip install python-multipart, then restart uvicorn.
  4. KeyError: 'JWT_SECRET' at startup. Your .env is missing or not loaded. Confirm the file sits next to where you run the app and that load_dotenv() runs before you read the variable.

When to use this vs. alternatives

  • JWT access tokens (this guide): Best for APIs and AI SaaS backends where clients call you with a bearer token. They are stateless, so any server can verify a request without a shared session store, which scales cleanly. Use this when you control the client and serve JSON.
  • Server-side sessions with a cookie: Better for a classic server-rendered website where the browser holds a session cookie and the server keeps session state. They are trivial to revoke instantly, but they need a shared session store once you run more than one server, which adds infrastructure.
  • A managed auth provider (Auth0, Clerk, Supabase Auth): Best when you need social login, password resets, and multi-factor auth without building them. You trade a monthly cost and an external dependency for not maintaining auth code yourself. Reach for this once auth becomes a distraction from your actual product.

Next steps

With auth in place, add the two guardrails that protect your margin: meter each user with Rate-Limit AI API Calls in a SaaS with Python so no one runs up your bill, then charge them with Add Stripe Billing to an AI SaaS with Python. Back to SaaS MVP with Python and AI.