Choosing an Authentication Approach for a Small Flask Service
Small services typically need one of three authentication styles: session-based (browser-first), opaque token-based (API-first with server-side token storage), or JWT-based (API-first with self-contained tokens). The right choice depends on client type (browser vs mobile/service-to-service), deployment topology (single instance vs multiple), and operational needs (revocation, rotation, observability).
| Approach | Best fit | Strengths | Trade-offs |
|---|---|---|---|
| Session-based (cookie + server session) | Browser apps, admin panels | Easy logout/revocation, simple mental model | CSRF considerations, sticky sessions or shared store for multi-instance |
| Opaque token (random token stored server-side) | Mobile apps, simple APIs needing revocation | Revocable, can store metadata, short tokens | Requires DB/Redis lookup each request |
| JWT (signed token with claims) | Service-to-service, stateless APIs, edge-friendly | No DB lookup for auth, scalable | Harder revocation, careful key management, claim validation pitfalls |
Threat model basics you should decide up front
- Do you need immediate revocation? If yes, sessions or opaque tokens are simpler than JWT.
- Will you run multiple instances? If yes, sessions need a shared store (Redis/DB) or signed-cookie sessions; opaque tokens need shared storage; JWT works well statelessly.
- Is the client a browser? Cookies are convenient but require CSRF defenses; Authorization headers avoid CSRF but require secure storage on the client.
Structuring Authentication as a Blueprint/Module
A practical pattern is to keep authentication concerns in a dedicated module with: routes (login/logout/refresh), utilities (hashing, token generation), and request-time identity attachment (before_request). The blueprint exposes endpoints; the rest of the app consumes a single function like current_user() or g.user.
# auth/__init__.py (conceptual layout, not repeating app factory details) from flask import Blueprint auth_bp = Blueprint("auth", __name__, url_prefix="/auth") from . import routes # registers endpoints # auth/routes.py from flask import request, jsonify from .services import authenticate_password, issue_tokens, revoke_refresh_token from .validators import require_json_fields from .authn import login_required from .permissions import require_role from flask import g from . import auth_bp @auth_bp.post("/login") def login(): data = request.get_json(silent=True) or {} require_json_fields(data, ["email", "password"]) user = authenticate_password(data["email"], data["password"]) if not user: return jsonify({"error": "invalid_credentials"}), 401 tokens = issue_tokens(user) return jsonify(tokens), 200 @auth_bp.post("/logout") @login_required def logout(): # If using refresh tokens, revoke the active refresh token (or all for user). revoke_refresh_token(g.auth.refresh_token_id) return jsonify({"ok": True}), 200 Password Hashing and Credential Storage (Safe Defaults)
Store only password hashes (never plaintext)
Use a slow, adaptive password hashing algorithm (Argon2 preferred; bcrypt acceptable). Do not use SHA-256 or MD5 for passwords. Store: password_hash plus any algorithm parameters embedded by the library.
- Argon2: strong against GPU attacks; recommended for new systems.
- bcrypt: widely supported; still solid.
Step-by-step: hashing and verifying
# auth/crypto.py from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError ph = PasswordHasher() def hash_password(password: str) -> str: return ph.hash(password) def verify_password(password_hash: str, password: str) -> bool: try: return ph.verify(password_hash, password) except VerifyMismatchError: return False Operational notes:
- Enforce minimum password length and rate-limit login attempts (rate limiting can be done at the edge or via a simple in-app limiter).
- Consider upgrading hashes over time: Argon2 libraries can detect when parameters changed and rehash after successful login.
- Store user identifiers and unique emails; normalize email casing consistently.
Credential storage model (conceptual)
Even if your user table already exists, the auth-relevant fields typically include:
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
id(primary key)email(unique)password_hashis_active(boolean)rolesor a relationship to roles/permissionscreated_at,last_login_at(optional but useful)
Session-Based Authentication (Cookie + Server Session)
Session auth is a good fit for browser-based UIs where cookies are convenient. The server sets a session cookie after login; subsequent requests include it automatically.
How it works
- User posts credentials to
/auth/login. - Server verifies password and stores
user_idin the session. - Server returns a response that sets a session cookie.
- Protected endpoints check
session["user_id"].
Implementation sketch
# auth/session_auth.py from flask import session, g from functools import wraps from .users import get_user_by_id def login_user(user): session["user_id"] = user.id def logout_user(): session.pop("user_id", None) def login_required(view): @wraps(view) def wrapper(*args, **kwargs): uid = session.get("user_id") if not uid: return {"error": "unauthorized"}, 401 user = get_user_by_id(uid) if not user or not user.is_active: return {"error": "unauthorized"}, 401 g.user = user return view(*args, **kwargs) return wrapper CSRF and cookie settings
With cookie-based auth, browsers automatically attach cookies, so CSRF is a real risk for state-changing requests. Practical mitigations:
- Use CSRF tokens for form posts or for JSON endpoints if accessed by browsers.
- Set cookies with
HttpOnly,Secure(in production), and a sensibleSameSitepolicy (oftenLaxfor typical apps;NonerequiresSecure).
Opaque Token Authentication (Server-Stored Tokens)
Opaque tokens are random strings (or random IDs) that have no meaning to clients. The server stores a hashed version of the token and looks it up on each request. This is a strong default for small APIs that need revocation and simple semantics.
Token format and storage
- Generate a high-entropy token (e.g., 32+ bytes).
- Store only a hash of the token in your database (like passwords), not the token itself.
- Store metadata:
user_id,expires_at,revoked_at,scopes,created_at,last_used_at.
Step-by-step: issuing an access token
# auth/tokens.py import secrets, hashlib from datetime import datetime, timedelta, timezone def _hash_token(token: str) -> str: # Use a one-way hash; include a server-side pepper if desired. return hashlib.sha256(token.encode("utf-8")).hexdigest() def generate_access_token() -> str: return secrets.token_urlsafe(32) def issue_access_token_for_user(user_id: int, ttl_minutes: int = 15): raw = generate_access_token() token_hash = _hash_token(raw) expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes) # persist token_hash, user_id, expires_at, revoked_at=NULL # return raw to client once return raw, expires_at Client usage: send Authorization: Bearer <token>. Server: hash presented token and find a matching non-revoked, non-expired record.
Protected endpoint with token lookup
# auth/authn.py from flask import request, g from functools import wraps from .token_store import find_token def _get_bearer_token(): auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): return None return auth.removeprefix("Bearer ").strip() def login_required(view): @wraps(view) def wrapper(*args, **kwargs): raw = _get_bearer_token() if not raw: return {"error": "unauthorized"}, 401 token = find_token(raw) # internally hashes and queries store if not token: return {"error": "unauthorized"}, 401 g.user = token.user # attach identity g.auth = token # attach auth context (scopes, token id) return view(*args, **kwargs) return wrapper Expiration and refresh strategy (opaque tokens)
A common pattern is short-lived access tokens plus long-lived refresh tokens:
- Access token TTL: 10–30 minutes.
- Refresh token TTL: days to weeks.
- Refresh endpoint exchanges a valid refresh token for a new access token (and often rotates the refresh token).
# auth/routes.py (refresh sketch) @auth_bp.post("/refresh") def refresh(): data = request.get_json(silent=True) or {} refresh_token = data.get("refresh_token") if not refresh_token: return {"error": "missing_refresh_token"}, 400 # validate refresh token in store (hashed lookup, not revoked, not expired) # rotate: revoke old refresh token, issue new refresh + new access return {"access_token": "...", "expires_in": 900, "refresh_token": "..."}, 200 Refresh token rotation reduces replay risk: if a refresh token is stolen and reused after rotation, you can detect reuse and revoke the token family.
JWT-Based Authentication (Signed Tokens with Claims)
JWTs are self-contained tokens signed by the server. They are attractive when you want to avoid a database lookup on each request. For small services, JWTs are often used for service-to-service calls or mobile clients where you can tolerate limited revocation.
JWT essentials you must validate
exp: expiration time (required).issandaud: issuer and audience (recommended to prevent token confusion across services).sub: subject (user id or service id).- Signature algorithm: allow only the expected algorithm (e.g., HS256 or RS256), never accept
none.
Signing keys: HS256 vs RS256
- HS256 (shared secret): simplest; keep the secret strong and private; rotate carefully.
- RS256 (public/private key): better for multi-service verification; private key signs, public key verifies.
Implementation sketch: issuing and verifying JWT
# auth/jwt.py import jwt from datetime import datetime, timedelta, timezone def issue_jwt(user, secret: str, issuer: str, audience: str, ttl_minutes: int = 15): now = datetime.now(timezone.utc) payload = { "sub": str(user.id), "iss": issuer, "aud": audience, "iat": int(now.timestamp()), "exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()), "roles": user.roles, } return jwt.encode(payload, secret, algorithm="HS256") def verify_jwt(token: str, secret: str, issuer: str, audience: str): return jwt.decode( token, secret, algorithms=["HS256"], issuer=issuer, audience=audience, options={"require": ["exp", "iss", "aud", "sub"]}, ) Even with JWT, you often still need a user lookup to check is_active or load permissions. If you want fully stateless auth, you must accept that user deactivation won’t take effect until tokens expire (unless you add a revocation list).
JWT refresh strategies
- Short access JWT + refresh token (opaque): common and practical. JWT is stateless for access; refresh remains revocable.
- Refresh JWT: possible but revocation is hard; rotation and token family tracking becomes complex.
- Revocation list: store revoked JWT IDs (
jti) until they expire; adds state back in.
Common Mistakes and How to Avoid Them
Storing plaintext tokens
If your database leaks and you stored raw access/refresh tokens, attackers can immediately use them. Store only a hash of opaque tokens (similar to password hashing). For JWT, you typically don’t store them, but if you do (e.g., for audit), treat them as sensitive and encrypt at rest.
Weak secrets and poor key rotation
- Use long, random secrets for HS256 (at least 256 bits of entropy).
- Do not hardcode secrets in source control.
- Plan rotation: support multiple active keys (key IDs /
kid) so old tokens can still verify during a transition.
Over-trusting JWT claims
Do not treat JWT claims as authoritative if your authorization model changes frequently. For example, if roles can be removed, a JWT containing old roles remains valid until expiration. Mitigations: short TTL, or check roles from DB for sensitive actions.
Not validating issuer/audience
Skipping iss/aud checks can allow tokens minted for another service to be accepted by yours.
Long-lived access tokens
Long access token TTL increases the blast radius of token theft. Prefer short-lived access tokens and refresh flows.
Protected Endpoints and Permission Checks
Decorator-based protection
A small service often uses a login_required decorator plus a require_role or require_scope decorator layered on top.
# auth/permissions.py from functools import wraps from flask import g def require_role(role: str): def decorator(view): @wraps(view) def wrapper(*args, **kwargs): user = getattr(g, "user", None) if not user: return {"error": "unauthorized"}, 401 if role not in (user.roles or []): return {"error": "forbidden", "missing_role": role}, 403 return view(*args, **kwargs) return wrapper return decorator # some_api/routes.py from flask import Blueprint, jsonify, g from auth.authn import login_required from auth.permissions import require_role api_bp = Blueprint("api", __name__) @api_bp.get("/me") @login_required def me(): return jsonify({"id": g.user.id, "email": g.user.email, "roles": g.user.roles}) @api_bp.delete("/admin/users/<int:user_id>") @login_required @require_role("admin") def delete_user(user_id): # perform deletion return jsonify({"ok": True}) Attaching User Identity to Request Context (A Reusable Pattern)
Instead of repeating token parsing and user lookup in every decorator, you can attach identity once per request using a blueprint-level before_app_request hook. This makes it easy to support multiple auth mechanisms (session + bearer token) and centralizes validation.
Pattern: resolve identity early, enforce later
- Resolver: inspects request (cookie session or Authorization header), validates credentials, and sets
g.userandg.auth. - Enforcer: decorators like
login_requiredandrequire_roleonly checkg.
# auth/context.py from flask import g, request from .session_auth import get_user_from_session from .token_auth import get_user_from_bearer_token def resolve_identity(): g.user = None g.auth = None # Prefer Authorization header for APIs; fall back to session for browser routes. authz = request.headers.get("Authorization") if authz: user, auth_ctx = get_user_from_bearer_token(authz) g.user, g.auth = user, auth_ctx return user = get_user_from_session() if user: g.user = user g.auth = {"method": "session"} # auth/hooks.py from .context import resolve_identity from . import auth_bp @auth_bp.before_app_request def _attach_identity(): resolve_identity() Permission verification using attached context
Once g.user is consistently attached, permission checks become uniform. For token-based auth, g.auth can also carry scopes, token id, or client id for auditing and fine-grained authorization.
# auth/permissions.py (scope-based example) def require_scope(scope: str): def decorator(view): @wraps(view) def wrapper(*args, **kwargs): if not getattr(g, "user", None): return {"error": "unauthorized"}, 401 scopes = set((getattr(g, "auth", None) or {}).get("scopes", [])) if scope not in scopes: return {"error": "forbidden", "missing_scope": scope}, 403 return view(*args, **kwargs) return wrapper return decorator