JWT HS256 + bcrypt Auth Stack
What
The repeatable auth stack for service-to-service or user-facing APIs in the FCA project: bcrypt password hashes via passlib, HS256-signed JWTs via python-jose, a minimum 32-character SECRET_KEY enforced at startup, and a single access_token_expire_minutes knob to control the session lifetime.
Project Context
In full_project_context_updated.txt -> app/config.py, the auth settings block declares jwt_algorithm: str = "HS256", access_token_expire_minutes: int = 3000 (≈50 hours), secret_key: str = Field(default=..., min_length=32) with a Pydantic @field_validator that raises ValueError in production if the default placeholder is still present. requirements.txt pins passlib[bcrypt], bcrypt==3.2.2, python-jose[cryptography], and python-multipart — the exact set any new auth route needs.
How
Settings block enforcing minimum secret length in production only
secret_key: str = Field(
default="your-secret-key-change-in-production",
min_length=32,
description="Secret key for encryption (min 32 chars)",
)
jwt_algorithm: str = Field(
default="HS256",
description="Algorithm used for JWT token signing",
)
access_token_expire_minutes: int = Field(
default=3000,
description="Time until access token expires",
)
@field_validator("secret_key")
@classmethod
def validate_secret_key(cls, v, info):
environment = info.data.get("environment", "development")
if environment == "production" and v == "your-secret-key-change-in-production":
raise ValueError(
"CRITICAL SECURITY: Must change SECRET_KEY in production! "
"Generate with: openssl rand -hex 32"
)
return vmin_length=32is enforced by Pydantic at field construction; a 16-character key raises before the app can boot.- The cross-field validator only triggers in
production, so a developer can run locally with a known weak key without false alarms. - The default
HS256is symmetric (single secret) — simpler thanRS256(key pair) but appropriate for a single-service issuer.
bcrypt hashing + JWT signing (auth dependency, pattern sketch)
from passlib.context import CryptContext
from jose import jwt, JWTError
from datetime import datetime, timedelta, timezone
from app.config import settings
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_ctx.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_ctx.verify(plain, hashed)
def issue_access_token(subject: str, scopes: list[str]) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": subject,
"scopes": scopes,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=settings.access_token_expire_minutes))
.timestamp()),
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict | None:
try:
return jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
except JWTError:
return NoneCryptContextlets you migrate from one scheme to another (bcrypt → argon2) by re-hashing on next successful login.datetime.now(timezone.utc)returns a tz-aware UTC datetime; pass tojwt.encode()after converting to Unix seconds.jwt.decode(...)accepts a list of allowed algorithms so the verifier cannot be tricked into accepting anone-alg token.
Common Pitfalls
Forgetting min_length=32 on secret_key lets devs commit "secret" and the production validator will accept it. Always set the constraint.
Issuing tokens without iat and exp makes them eternal. Both fields are required for any sane token-replay defense.
Defaulting to algorithms=["HS256"] but accepting the alg from the token header enables the classic alg=none downgrade exploit. Always pin the allowed list on jwt.decode.
Real-World Interview Prep
Q1: When would you choose HS256 vs RS256 vs ES256 for JWT signing?
A: HS256 (symmetric, single secret) when the issuer and verifier are the same service — simplest, fastest, no key rotation overhead. RS256 (asymmetric, RSA key pair) when multiple services verify but only ONE service issues (e.g., Auth0 issues, downstream services verify). RS256 lets verifiers validate without the issuing secret; rotating the secret is a signing-side-only operation. ES256 (asymmetric, ECDSA) is the modern choice when key size matters: 256-bit EC keys vs 2048-bit RSA, less CPU per verify, smaller tokens. For the FCA stack a single issuer + few verifiers, HS256 is acceptable; if you add a federated verifier (e.g., cross-org trust), switch to RS256.
Q2: How would you design a refresh-token flow for this stack?
A: Three layers. (1) Issue a short-lived access token (5–15 min) PLUS a long-lived refresh token (7–30 days). (2) Store refresh tokens in a Redis SET indexed by user; on every refresh, rotate the token (refresh_token_rotation). (3) On logout, push the refresh jti into a Redis blacklist with TTL = remaining token lifetime. Without rotation, a leaked refresh token stays valid until expiry. Without the blacklist, a logged-out user can still refresh for up to 7 days. The HS256 setup here only does access tokens — for refresh you’d extend create_access_token to accept a refresh flag with longer expiry and add a /auth/refresh endpoint.
Q3: Walk through the token storage choices in the browser.
A: Three options, ranked by risk. (1) httpOnly + Secure + SameSite=Strict cookie (best) — immune to XSS exfil, immune to most CSRF because of SameSite. The FastAPI login endpoint sets it; the browser sends it automatically. (2) In-memory JS variable (medium) — immune to XSS exfil from disk but vulnerable to in-page JS injection. (3) localStorage/sessionStorage (worst) — every XSS exfils it; trivial to scrape from dev-tools. For the FCA app, storing the JWT in st.session_state.token (Streamlit) is option 2; the production version should migrate to cookie-based auth so the Next.js / Streamlit frontend is hardened against XSS.
Top-to-Bottom Code Walkthrough (app/services/security_service.py — JWT sign/verify + app/api/routes/auth.py)
JWT (JSON Web Token) is the sessionless token that travels with every request. Combined with bcrypt hashing, it’s the project’s defensive onboarding.
Create the token
from jose import jwt
from datetime import datetime, timedelta
def create_access_token(self, payload: dict) -> str:
to_encode = payload.copy()
to_encode["iat"] = datetime.utcnow()
to_encode["exp"] = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm)Three claims are added:
iat(issued-at) — when the token was created.exp(expiration) — when it stops working.type(custom) —"access"or"refresh"if you implement refresh tokens.
Verify the token
def decode_token(self, token: str) -> dict:
return jwt.decode(
token,
settings.secret_key,
algorithms=[settings.jwt_algorithm], # CRITICAL: list, not str
)algorithms=[...] in a list: prevents the algorithm-confusion attack where an attacker forges a token signed with alg=none and the server accepts it. Never decode without specifying algorithm(s).
JWT structure breakdown
A token looks like eyJhbGc....Xyz.Abc-def-_. Splitting on .:
- Header (base64):
{"alg": "HS256", "typ": "JWT"} - Payload (base64): user data + iat/exp
- Signature: HMAC-SHA256(header.payload, secret_key)
The login route
@router.post("/login")
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
user = await db.execute(select(Customer).where(Customer.email == form.username))
if not security_service.verify_password(form.password, user.hashed_password):
raise HTTPException(401, "Invalid credentials")
token = security_service.create_access_token({
"sub": str(user.id),
"email": user.email,
"role": user.role,
"scopes": user.scopes.split(" "),
})
return {"access_token": token, "token_type": "bearer"}Why sub: standard JWT claim naming. Clients can read sub to learn the user ID — but never trust it for access decisions.
Refresh endpoint (optional)
@router.post("/refresh")
async def refresh(form: RefreshTokenForm = Depends()):
payload = security_service.decode_token(form.refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(401, "Not a refresh token")
return {"access_token": security_service.create_access_token({"sub": payload["sub"], "type": "access"})}Settings enforcement
secret_key: str = Field(default="...", min_length=32)A 32-character minimum blocks weak HMAC keys (256 bits is the symmetric equivalent of RSA-2048 security).
Where the secret comes from in production
Set SECRET_KEY env var to a 64-char hex random string (from openssl rand -hex 32). The validator in Settings.validate_secret_key() refuses to boot in production if the placeholder default is unchanged.
Common Pitfalls
Storing secret_key in git — Settings.validate_secret_key blocks prod startup, but it’s too late: the key has been pushed. Use a secrets manager (AWS Secrets Manager, HashiCorp Vault).
Verifying without algorithms=[...] — opens the alg=none attack. Always specify.
Including PII in JWT payload — anyone with browser dev tools reads the unencrypted payload. Put only user IDs, roles, scopes; never emails or names.
Long token expiry without refresh — gives attackers a long window. Default 5-60 minutes with 7-day refresh.
Real-World Interview Prep
Q1: JWT vs session cookies?
A: JWT travels in the Authorization: Bearer header. Cookies are auto-attached by browsers for same-origin requests. JWT works across origins (mobile apps, 3rd-party APIs); cookies work for browser-only SPAs without an OAuth flow.
Q2: What’s the difference between HS256 and RS256?
A: HS256 (HMAC-SHA256) is symmetric — same key signs and verifies. Fast, simple, fits a single-server deploy. RS256 (RSA-SHA256) is asymmetric — the issuer has the private key, anyone can verify with the public key. Best for microservice fleets.
Q3: How do you revoke a JWT before its expiry?
A: JWTs are stateless; you cannot directly revoke. Patterns:
- Short expiry + refresh token (server-stored) — rotate often.
- Token blocklist — store
jtiof revoked tokens; check on each request. Defeats the statelessness. - Logout endpoint that creates an iat threshold — anyone with iat < threshold is rejected.