Authentication
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:
- Salt: A random value added before hashing. Even if two admins have the same password, their hashes will be different. This defeats "rainbow table" attacks (precomputed hash tables).
- Cost factor: bcrypt is intentionally slow โ it runs thousands of rounds of computation. This makes brute-force attacks impractical. The cost can be increased as hardware gets faster.
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.
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.
@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:
- httpOnly means JavaScript running in the browser cannot read the cookie.
document.cookiewon't show it. - This protects against XSS (Cross-Site Scripting) attacks โ if malicious JS is injected into the page, it can't steal the token.
- The browser automatically sends the cookie on every request to the same domain โ no JS needed.
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?
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?
4. Why is the JWT stored in an httpOnly cookie rather than localStorage?
5. The admin login form has an "Email" field but the FastAPI endpoint reads form.username. Why?
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.