โ† Alembic Migrations 06 / 18 Next: Payments โ†’

Authentication

Module 2 ยท Backend ยท โฑ ~30 min
Why this matters: The admin panel must only be accessible to shop owners. This is enforced through bcrypt password hashing (so we never store real passwords) and JWT tokens (so the server knows who you are on every request, without storing sessions).

Why We Don't Store Plain Passwords

If you store passwords as plain text and the database is compromised, every user's password is exposed. Instead, we store a hash โ€” a one-way transformation that can't be reversed.

When a user logs in, we hash their input and compare the hash to the stored hash. The original password is never stored anywhere.

bcrypt: Password Hashing

bcrypt is the hashing algorithm JongYang uses. Two key features:

python โ€” backend/app/core/security.py
import bcrypt
from jose import JWTError, jwt
from datetime import datetime, timedelta, timezone
from app.core.config import settings

def hash_password(password: str) -> str:
    # bcrypt.gensalt() generates a random salt and embeds the cost factor
    # The result looks like: $2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
    return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()

def verify_password(plain: str, hashed: str) -> bool:
    # Extracts the salt from the hash, hashes 'plain' the same way, compares
    return bcrypt.checkpw(plain.encode(), hashed.encode())
hash_password("Demo1234!") produces something like:
  $2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
   โ”‚    โ”‚  โ”‚                                                    โ”‚
   โ”‚    โ”‚  โ””โ”€โ”€ 53 chars: salt (first 22) + hash (remaining)    โ”‚
   โ”‚    โ””โ”€โ”€โ”€โ”€โ”€ cost factor: 2^12 = 4096 iterations             โ”‚
   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ algorithm version                                โ”‚

verify_password("Demo1234!", "$2b$12$N9qo8...") โ†’ True
verify_password("WrongPass", "$2b$12$N9qo8...") โ†’ False

JWT: JSON Web Tokens

After verifying the password, we create a JWT โ€” a token the client sends on every subsequent request to prove their identity. The server doesn't need to store session state; all info is in the token.

A JWT has three parts separated by dots: header.payload.signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkNWU4MDJmZC03YzQwLTQ2MDAtODJmMS0zZTJiOWQ3NzYzZjIiLCJzaG9wX2lkIjoiZmEyZmQ4MTAtMDRhOC00MWY3LWJjYzEtZGNiMzg4OWI5YWMxIiwiZXhwIjoxNzgyMjI0OTAxfQ.xxx
โ”‚                                     โ”‚ โ”‚                                                                                                                          โ”‚ โ”‚   โ”‚
โ””โ”€โ”€โ”€โ”€ header (base64) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€ payload (base64) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””sigโ”˜

Decoded payload (not encrypted โ€” anyone can read it!):
{
  "sub": "d5e802fd-7c40-4600-82f1-3e2b9d7763f2",  โ† admin's UUID
  "shop_id": "fa2fd810-04a8-41f7-bcc1-dcb3889b9ac1",
  "exp": 1782224901  โ† expiry timestamp (24 hours from now)
}

Signature: HMAC-SHA256(base64(header) + "." + base64(payload), SECRET_KEY)
The signature is what makes the token trustworthy. Only our server knows SECRET_KEY.
JWT payloads are NOT encrypted โ€” only signed. Anyone who intercepts the token can decode and read the payload. Never put sensitive data (passwords, credit card numbers) in a JWT payload. The signature only proves the token wasn't tampered with.
python โ€” creating and decoding JWTs (security.py)
def create_access_token(data: dict) -> str:
    expire = datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS)
    # Add expiry to the payload, then encode with the secret key
    return jwt.encode({**data, "exp": expire}, settings.SECRET_KEY, algorithm="HS256")

def decode_access_token(token: str) -> dict:
    try:
        # Verifies the signature AND checks expiry automatically
        return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
    except JWTError:
        raise ValueError("Invalid or expired token")

The Full Admin Login Flow

1. Admin fills email + password in browser
   โ””โ”€โ”€ Submits to Next.js API route: POST /api/admin/login (JSON)

2. Next.js API route (app/api/admin/login/route.ts)
   โ””โ”€โ”€ Converts JSON to form-data (username=email&password=...)
   โ””โ”€โ”€ POSTs to FastAPI: POST /api/admin/auth/login

3. FastAPI (routers/admin/auth.py)
   โ””โ”€โ”€ Receives OAuth2PasswordRequestForm (username + password)
   โ””โ”€โ”€ Finds ShopAdmin by email in DB
   โ””โ”€โ”€ verify_password(form.password, admin.hashed_password)
   โ””โ”€โ”€ If correct: create_access_token({sub: admin.id, shop_id: admin.shop_id})
   โ””โ”€โ”€ Returns: {"access_token": "eyJ...", "token_type": "bearer"}

4. Next.js API route receives the token
   โ””โ”€โ”€ Sets httpOnly cookie "admin_token" = the JWT
   โ””โ”€โ”€ Returns {"ok": true} to the browser

5. Browser receives {"ok": true}
   โ””โ”€โ”€ useRouter().push('/admin/dashboard')

6. Every subsequent admin API call
   โ””โ”€โ”€ Next.js proxy reads cookie server-side
   โ””โ”€โ”€ Adds "Authorization: Bearer eyJ..." to FastAPI request
   โ””โ”€โ”€ FastAPI's get_current_admin() decodes the JWT
   โ””โ”€โ”€ Returns the ShopAdmin object to the route handler

Why OAuth2PasswordRequestForm uses "username" not "email"

OAuth2 is a standard that specifies form fields as username and password. FastAPI's OAuth2PasswordRequestForm expects exactly these field names. Our system uses email as the username โ€” the field is named "username" by the OAuth2 spec but contains an email address.

python โ€” backend/app/routers/admin/auth.py
@router.post("/login", response_model=TokenOut)
async def login(
    form: OAuth2PasswordRequestForm = Depends(),  # reads username + password from form-data
    session: AsyncSession = Depends(get_db),
):
    admin = (await session.execute(
        select(ShopAdmin).where(ShopAdmin.email == form.username)  # "username" field = email
    )).scalar_one_or_none()

    if admin is None or not verify_password(form.password, admin.hashed_password):
        raise HTTPException(status_code=401, detail="Invalid email or password")

    token = create_access_token({"sub": str(admin.id), "shop_id": str(admin.shop_id)})
    return TokenOut(access_token=token)

httpOnly Cookies: The Security Choice

After login, the JWT is stored in an httpOnly cookie, not in localStorage or a JavaScript variable. This is deliberate:

The Next.js server reads the cookie server-side using cookies() from next/headers โ€” the browser's JS never touches the token directly.

๐Ÿง  Self-Check Quiz

1. Why does bcrypt include a random salt in every hash, even when hashing the same password twice?

Without salt, if two users have the same password (e.g., "Demo1234!"), their hashes would be identical. An attacker who precomputes hashes for common passwords (a "rainbow table") could find matches instantly. Salt makes every hash unique โ€” even the same password produces a different hash each time โ€” so precomputed tables are useless. The salt is stored inside the hash string so it's available for verification.

2. What three pieces of information are in the JWT payload when an admin logs in?

sub (the admin's UUID), shop_id (which shop this admin belongs to), and exp (expiry timestamp, 24 hours from login). The shop_id is embedded so every admin request knows which shop's data to filter โ€” without needing another DB query to look it up.

3. JWT payloads are base64-encoded, not encrypted. Why is this OK for the admin JWT?

The payload only contains non-sensitive identifiers โ€” the admin's UUID and shop UUID. These aren't useful to an attacker on their own. The critical thing is the signature: even if someone reads the payload, they can't forge a token for a different admin without knowing the SECRET_KEY. The signature proves the token came from our server and wasn't modified.

4. Why is the JWT stored in an httpOnly cookie rather than localStorage?

localStorage can be read by any JavaScript on the page โ€” including injected malicious scripts (XSS attacks). If an attacker injects JS, they can steal the token and impersonate the admin. httpOnly cookies cannot be read by JavaScript at all โ€” only the browser (for automatic inclusion in requests) and the server (server-side cookie reading) can access them. This eliminates the XSS token-theft attack.

5. The admin login form has an "Email" field but the FastAPI endpoint reads form.username. Why?

OAuth2 Password Flow is a standard that requires fields named username and password. FastAPI's OAuth2PasswordRequestForm follows this standard. The Next.js route converts the JSON body {email, password} to form-data username=email&password=pass before sending to FastAPI. Our system uses email as the username โ€” the field name is a standard constraint, not a design choice.