Backend Development
Hands-onIntermediate 40 min· Production-ready APIs · 9 of 20

Authentication: sessions, JWT and OAuth

Learn how login really works: store passwords safely with bcrypt, issue and verify JWTs in FastAPI, protect routes by role, compare sessions with tokens, and understand the OAuth flow behind Sign in with Google.

Authentication answers "who are you?". Authorisation answers "what are you allowed to do?". Brew & Bean needs both: any customer can place an order, but only staff may change an order's status to preparing or ready.

What you'll build: a FastAPI service with /register, /login (returns a signed token), a protected /me route, and a staff-only route. Along the way you will learn how passwords must be stored, how sessions and JWTs differ, and what OAuth ("Sign in with Google") really does.

Prerequisites: Python 3.10+, basic FastAPI (see *Build an API with Python and FastAPI*) and curl. Input checking is covered in *Validation and error handling*, and the wider threat list in *API security essentials*.

Passwords: never store them

If your database leaks and holds plain passwords, every user is exposed, and many reuse that password elsewhere. So you store a hash: a one-way scrambled form. To log in you hash what the user typed and compare. A password hash must be slow and salted on purpose, so guessing billions of passwords is expensive. Use a purpose-built algorithm such as bcrypt or Argon2. Never use plain MD5 or SHA-256 for passwords, and never invent your own scheme.

How to store a password

ApproachSafe for passwords?Why
Plain textNoAnyone with the database sees everything
MD5 or SHA-256 onlyNoBuilt to be fast, so attackers can try huge numbers per second
bcrypt, Argon2 (with a salt)YesDeliberately slow, with a unique random salt per password

Sessions or tokens?

After login, the server must recognise the user on every later request, because HTTP forgets everything between requests. There are two common designs.

Session: the server creates a random session ID, stores the session data (who this is) on the server, in memory, a database or Redis, and sends the ID in a cookie. Each request carries the cookie and the server looks it up. It is simple and easy to log out (delete the session). It needs shared storage when you run several servers.

Token (JWT): the server gives the client a signed token that itself contains the user's id and an expiry. The client sends it in the Authorization: Bearer <token> header. The server checks the signature and needs no lookup. It suits APIs used by mobile apps and other services. The catch: a valid token cannot be easily cancelled before it expires.

Sessions vs JWT

QuestionSession cookieJWT bearer token
Where is the state?On the serverInside the token
Sent how?Cookie, automatically by the browserHeader, set by your client code
Log out / revokeDelete the session, instantWait for expiry, or keep a deny-list
Good forServer-rendered sites, same-site web appsMobile apps, APIs, multiple services
Main risksCSRF (use SameSite cookies and CSRF protection)Token theft; long-lived tokens

Neither is "better". Many web apps are fine with sessions. This lesson builds the JWT version because most API job interviews ask about it.

What is inside a JWT

A JWT (JSON Web Token) has three parts joined by dots: header.payload.signature. The header and payload are just Base64-encoded JSON, readable by anyone. The signature is made with the server's secret key, so if anyone changes the payload the signature check fails.

Typical payload fields are sub (the subject, here the user's email), exp (expiry time) and your own claims such as role.

Anyone can read the payload

A JWT is signed, not encrypted. Never put passwords, card numbers or other secrets in the payload. Paste any token into a JWT decoder and you can read it.

Build it

Setup

  1. 1Make a folder auth-demo and a virtual environment: python -m venv .venv, then activate it (source .venv/bin/activate on macOS/Linux, .venv\Scripts\activate on Windows).
  2. 2Install the packages: pip install fastapi uvicorn pyjwt bcrypt.
  3. 3Create main.py with the code below.
  4. 4Run the server: uvicorn main:app --reload.
  5. 5Try it with the curl commands further down, or open http://127.0.0.1:8000/docs.
Terminal
pip install fastapi uvicorn pyjwt bcrypt
main.py
import os
from datetime import datetime, timedelta, timezone

import bcrypt
import jwt
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field

SECRET_KEY = os.environ.get("JWT_SECRET", "dev-only-secret-change-me-before-deploying")
ALGORITHM = "HS256"
TOKEN_MINUTES = 30

app = FastAPI(title="Brew & Bean auth demo")
bearer = HTTPBearer()


def hash_password(password: str) -> str:
    return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()


def verify_password(password: str, password_hash: str) -> bool:
    return bcrypt.checkpw(password.encode(), password_hash.encode())


# A fake database: email -> user. Replace with a real table (see ORMs and migrations).
users: dict[str, dict] = {
    "barista@brewbean.in": {
        "email": "barista@brewbean.in",
        "password_hash": hash_password("barista-pass-123"),
        "role": "staff",
    }
}


class Credentials(BaseModel):
    email: str
    password: str = Field(min_length=8, max_length=64)


def create_token(email: str, role: str) -> str:
    payload = {
        "sub": email,
        "role": role,
        "exp": datetime.now(timezone.utc) + timedelta(minutes=TOKEN_MINUTES),
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


@app.post("/register", status_code=201)
def register(body: Credentials):
    if body.email in users:
        raise HTTPException(status_code=409, detail="Email already registered")
    users[body.email] = {
        "email": body.email,
        "password_hash": hash_password(body.password),
        "role": "customer",  # never let the client choose its own role
    }
    return {"email": body.email}


@app.post("/login")
def login(body: Credentials):
    user = users.get(body.email)
    # Same message for "no such user" and "wrong password"
    if user is None or not verify_password(body.password, user["password_hash"]):
        raise HTTPException(status_code=401, detail="Invalid email or password")
    return {"access_token": create_token(user["email"], user["role"]), "token_type": "bearer"}


def get_current_user(creds: HTTPAuthorizationCredentials = Depends(bearer)) -> dict:
    try:
        payload = jwt.decode(creds.credentials, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")
    user = users.get(payload["sub"])
    if user is None:
        raise HTTPException(status_code=401, detail="User no longer exists")
    return user


def require_role(role: str):
    def checker(user: dict = Depends(get_current_user)) -> dict:
        if user["role"] != role:
            raise HTTPException(status_code=403, detail="Not allowed")
        return user

    return checker


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


@app.patch("/orders/{order_id}/status")
def update_status(order_id: int, user: dict = Depends(require_role("staff"))):
    return {"order_id": order_id, "status": "preparing", "changed_by": user["email"]}

In-memory users dict stands in for a database table. The role is set by the server, never by the request.

Read the code in four parts.

1. Hashing. hash_password salts and hashes with bcrypt; verify_password compares. bcrypt only uses the first 72 bytes of a password, so the model caps passwords at 64 characters. 2. Register and login. Register refuses duplicate emails (409). Login returns the *same* message whether the email or the password was wrong, so attackers can't discover which emails exist. 3. The token. create_token puts sub, role and an expiry exp (30 minutes) into the payload and signs it with SECRET_KEY. PyJWT checks exp for you when decoding. 4. Dependencies. get_current_user reads the Bearer token, verifies it and loads the user. require_role("staff") builds on it. Any route that adds Depends(...) is protected.

Secret key

The default SECRET_KEY in the code is for local learning only. In a real deployment, set the JWT_SECRET environment variable to a long random value (for example the output of python -c "import secrets; print(secrets.token_hex(32))"), keep it out of Git, and never share it. Anyone with the key can forge tokens for any user.

Try it

Terminal
# 1. Register
curl -X POST http://127.0.0.1:8000/register -H "Content-Type: application/json" -d '{"email":"asha@example.com","password":"filter-coffee-1"}'

# 2. Log in and copy the access_token from the response
curl -X POST http://127.0.0.1:8000/login -H "Content-Type: application/json" -d '{"email":"asha@example.com","password":"filter-coffee-1"}'

# 3. Call a protected route (replace TOKEN)
curl http://127.0.0.1:8000/me -H "Authorization: Bearer TOKEN"

# 4. Try the staff-only route with the customer token
curl -X PATCH http://127.0.0.1:8000/orders/1/status -H "Authorization: Bearer TOKEN"

On Windows PowerShell use curl.exe and swap the single quotes around JSON for double quotes with escaped inner quotes, or use the /docs page.

What you should see (one line per step)
{"email":"asha@example.com"}
{"access_token":"eyJhbGciOiJIUzI1NiIs...","token_type":"bearer"}
{"email":"asha@example.com","role":"customer"}
{"detail":"Not allowed"}

Step 4 returns 403 because you are authenticated but not authorised. Compare the codes: 401 means "I don't know who you are" (missing or bad token), 403 means "I know who you are, and you may not do this". Now log in as the seeded staff user barista@brewbean.in with password barista-pass-123 and repeat step 4: it succeeds.

Short expiry

Keep access tokens short-lived (minutes, not months). Real systems pair a short access token with a longer-lived refresh token stored safely and revocable on the server, so a stolen access token stops working quickly.

OAuth and "Sign in with Google"

OAuth 2.0 lets a user give your app limited access to their account on another service without giving you their password. "Sign in with Google" (built on OpenID Connect, a layer on top of OAuth) lets Google do the authentication and tell your app who the user is. The usual flow is called the authorization code flow.

The authorization code flow

  1. 1The user clicks the Sign in with Google button in your app.
  2. 2Your app redirects the browser to Google's authorisation page with your client_id, a redirect_uri, the scope you want (such as openid email) and a random state value.
  3. 3The user logs in at Google and approves.
  4. 4Google redirects back to your redirect_uri with a short-lived code and your state.
  5. 5Your backend checks that state matches what it sent, then calls Google's token endpoint, sending the code plus your client_secret, and gets tokens in return.
  6. 6Your backend reads the user's verified email from the ID token, finds or creates that user in your database, and issues your own session or JWT.

Do not hand-write this in production. Use a maintained library (for Python, Authlib is a well-known one) or a service such as Auth0, Firebase Authentication or Keycloak. The security details (the state check, PKCE for public apps like mobile and single-page apps, token validation) are easy to get wrong. The provider's developer console is where you register your app to obtain the client_id and client_secret.

How this comes up in interviews

Expect: "How do you store passwords?" (salted slow hash such as bcrypt), "Session vs JWT?" (the table above), "Where do you store a JWT in the browser?" (trade-offs: an HttpOnly cookie is safer against XSS theft than localStorage, but then you need CSRF protection), "401 vs 403?" and "What is OAuth?" (delegated access through the authorization code flow). Be ready to say why JWTs are hard to revoke.

Prompt · Review my auth code
Act as a security reviewer for a FastAPI backend. Below is my authentication code. List weaknesses in order of severity, covering password storage, token creation and verification, secret handling, error messages, and role checks. For each, show the fix as a small code change.

[paste your main.py]

What to expect: A good review should mention secret management, token expiry, rate limiting on /login, HTTPS, and possible user-enumeration. Verify any library function it suggests exists before using it.

Try it: Add revocation with a token version

1. Add a token_version integer to each user, include it in the JWT payload, and reject tokens whose version no longer matches. Add a POST /logout-all route that increases the user's token_version. 2. Write down in two sentences how this solves the "JWT can't be revoked" problem and what it costs (a lookup on every request).

Try it: Break it on purpose

Log in, copy the token, change one character in the middle of it, and call /me. Then set TOKEN_MINUTES = 0 (restart the server), log in and call /me. What error does each give?

Auth checklist

  1. Passwords are hashed with bcrypt or Argon2, never stored in plain text
  2. The signing secret comes from an environment variable and is not in Git
  3. Tokens expire, and you have a plan for refresh and revocation
  4. Login errors do not reveal whether the email exists
  5. Roles are decided by the server, not by request data
  6. Everything runs over HTTPS in production

Key takeaways

  1. Authentication is who you are; authorisation is what you may do. 401 and 403 reflect that.
  2. Store passwords as slow, salted hashes (bcrypt or Argon2), never plain text or bare SHA-256.
  3. Sessions keep state on the server and are easy to revoke; JWTs keep state in a signed token and suit APIs.
  4. A JWT is signed, not encrypted: do not put secrets in it, and keep expiry short.
  5. OAuth lets another service vouch for the user via the authorization code flow; use a library instead of writing it yourself.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why should passwords be stored using bcrypt rather than SHA-256?

2. A request has a valid token for a customer and calls a staff-only route. Which status is correct?

3. What is true about the payload of a JWT?

4. Which is a real downside of JWTs compared with server-side sessions?

5. In the OAuth authorization code flow, why does the provider redirect back with a short-lived code instead of the tokens directly?

6. Why does the login route return the same message for an unknown email and a wrong password?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free