JWT HS256 + bcrypt AUTH — Data Flow
┌── SIGNUP ─────────────────────────────────────────┐
│ passlib.CryptContext(["bcrypt"]).hash(plain) │
│ │ auto salt, 12 rounds │
│ ▼ │
│ hashed_password ──► users.hashed_password │
└────────────────────────────────────────────────────┘
┌── LOGIN ──────────────────────────────────────────┐
│ POST /auth/jwt/login │
│ │ SELECT user WHERE email=:username │
│ ▼ │
│ verify_password(plain, hashed) ── constant time │
│ ▼ │
│ issue_access_token({sub, scopes, iat, exp}, │
│ settings.secret_key, "HS256") │
│ ▼ │
│ response: {access_token, scopes, token_type} │
└────────────────────────────────────────────────────┘
┌── SUBSEQUENT REQUEST ─────────────────────────────┐
│ Authorization: Bearer <jwt> │
│ │ jwt.decode(token, secret, algs=["HS256"])│
│ │ reject alg=none │
│ ▼ │
│ payload ──► get_current_user ──► endpoint │
└───────────────────────────────────────────────────┘
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.
In the fca project context -> 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.
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 v
min_length=32 is enforced by Pydantic at field construction; a 16-character key raises before the app can boot.production, so a developer can run locally with a known weak key without false alarms.HS256 is symmetric (single secret) — simpler than RS256 (key pair) but appropriate for a single-service issuer.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 None
CryptContext lets 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 to jwt.encode() after converting to Unix seconds.jwt.decode(...) accepts a list of allowed algorithms so the verifier cannot be tricked into accepting a none-alg token.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.
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.
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.
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.
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.
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.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).
A token looks like eyJhbGc....Xyz.Abc-def-_. Splitting on .:
{”alg”: “HS256”, ”typ”: “JWT”}@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.
@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"})}
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).
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.
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.
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.
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.
A: JWTs are stateless; you cannot directly revoke. Patterns:
jti of revoked tokens; check on each request. Defeats the statelessness.access_token_expire_minutes=3000 (50 h).localStorage accessible to JS.secret=<YOUR_ACTUAL_SECRET> in payload.