Business Apps

Add User Authentication to a Python AI App

Add login and JWT auth to a FastAPI AI app: hash passwords with bcrypt, issue access tokens, protect a route, and read the current user. Runnable Python.

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. If you only have a script that calls a model and no web app yet, Turn a Python AI Script into an API with FastAPI builds the app that this guide bolts auth onto. Everything below 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. The same rule covers your model provider keys: they belong in environment variables read at startup, never in the code you commit, and Manage API Keys Safely in Production covers what changes once the app leaves your laptop and runs on a server.

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)

The pay-off is easiest to see by imagining the worst day your product ever has: someone copies your users table and posts it online. What the attacker walks away with depends entirely on which of these two rows you chose to write.

The same database leak with plain-text passwords and with bcrypt hashes Two parallel rows. In the first, a users table storing raw passwords is leaked and every password is exposed. In the second, the table stores bcrypt hashes, so the leaked rows give the attacker nothing to reuse. Before: plain text users table password: hunter2 Database leak attacker copies rows Passwords exposed reused on other sites After: bcrypt hash users table hashed_password: $2b$ Database leak attacker copies rows Hashes are useless no password to reuse
Hashing does not prevent a database leak. It makes the stolen rows worthless, because a bcrypt hash cannot be turned back into the password it came from.

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.

fake_users: dict[str, dict] = {}  # stand-in "database"; use Postgres or SQLite later


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


create_user("founder@example.com", "supersecret123")  # seed one user to log in with

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)

Before you wire that into a route, know exactly what you are handing the user. A JWT is not encrypted. It is three chunks of base64 text joined by dots, and anyone who copies one out of a browser can decode the middle chunk and read it. What they cannot do is change it, because the third chunk is a signature over the first two, computed with your secret key.

The three dot-separated parts of a JWT access token A token split into header, payload and signature segments, with an annotation under each explaining what it contains: the signing algorithm, the user id and expiry claims, and the keyed hash that makes the token tamper-proof. One signed token Header base64, not secret . Payload your claims live here . Signature proves it is yours alg: HS256 typ: JWT you never edit it sub: user id exp: expiry time readable by anyone HMAC-SHA256 over parts 1 and 2 keyed by JWT_SECRET
Only the signature is protected by your secret; the header and payload are plain readable text, so put an id and an expiry in a token and nothing else.

That is why payload above carries a user id and an expiry rather than an email, a plan name, or anything you would mind a customer editing in a token viewer. Treat everything inside a token as public, and keep the values that matter on the server.

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

Read that dependency as a gate with exactly two exits. Every call to a protected route walks the same path: FastAPI lifts the token out of the header, hands it to get_current_user, and either a real user reaches your route or a 401 goes back to the caller and your route body never runs at all.

What happens to a request when the token check passes or fails A request to the generate endpoint reaches the get_current_user dependency, which decodes the token and looks up the user. A valid token lets the route body run with the user attached; an invalid or expired token returns HTTP 401 and the route body never executes. POST /generate Bearer token sent get_current_user decode, then look up valid invalid Route body runs current_user is set HTTP 401 returned route never runs
The dependency runs before your handler, so there is no code path in which an anonymous caller reaches the model call you pay for.

Two details are worth holding on to. Because the dependency runs first, a route that lists Depends(get_current_user) cannot accidentally execute for a caller without a token — you are not relying on remembering an if statement inside the handler. And because the expiry travels inside the token, an expired token fails inside jwt.decode with a JWTError, which the same except block converts into the same 401. Clients should read a 401 on a call that worked five minutes ago as "log in again", not as a bug.

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. Once real accounts are hitting the endpoint, Log and Monitor AI API Calls in Production shows how to record which user made each model call, which is the record you will want the first time a bill surprises you. Back to SaaS MVP with Python and AI.

Frequently asked questions

How do I add login to a FastAPI app?

Store each user's email and a bcrypt password hash, then expose a /login route that checks the password and returns a signed JWT access token. Clients send that token on every later request, and a dependency decodes it to identify the user.

Should I store passwords or password hashes?

Never store the raw password. Store only a one-way bcrypt hash created with passlib. When a user logs in you hash the submitted password and compare it to the stored hash, so a database leak never exposes real passwords.

What is a JWT access token?

A JWT (JSON Web Token) is a signed string that carries the user's identity and an expiry time. Your server signs it with a secret key, so it can later verify the token is genuine without a database lookup, which makes it fast for protecting API routes.

Where do I store the JWT secret key?

Put it in a .env file as a long random string and load it at startup, never hard-code it in your source. Anyone who knows the secret can forge valid tokens, so keep .env out of Git and rotate the key if it leaks.

How long should a JWT access token last?

Short, usually 15 to 60 minutes. A short expiry limits damage if a token is stolen. Pair it with a longer-lived refresh token once your app grows, but a single short access token is fine for an MVP.