JWT & API Authentication
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Flask 3.1.3, Werkzeug 3.1.9, Flask-JWT-Extended 4.7.4, PyJWT 2.15.1, Flask-HTTPAuth 4.8.1, Flask-CORS 6.0.5, flask-smorest 0.47.0 and Python 3.13, checked on 2026-10-07; every Python example was executed with those versions. Flask 3.2 is not released yet, and its unreleased changelog contains nothing that changes how token authentication is written. A browser that logs in to a server-rendered site carries a session cookie, and Flask-Login (see the authentication page of this section) manages it. An API is called by other kinds of clients: a single-page application, a mobile app, a command-line tool or another service. They have no session, so every request has to prove who is calling by itself. This page builds that half of the running Bookshelf application: signed tokens with Flask-JWT-Extended, validating tokens issued by somebody else with PyJWT, API keys for programs, and HTTP Basic for the simplest cases, plus the details that connect them to the rest of the stack (CORS preflight requests, ownership checks, OpenAPI security schemes and tests). The protocol theory is not repeated here; it lives in the OAuth 2.0 and OpenID Connect section, and the same ideas in FastAPI are covered by OAuth2 password flow and JWT in FastAPI.
Choosing an Authentication Mechanism
Flask itself has no concept of an authenticated API caller. The request object exposes the raw Authorization header
through request.authorization (a Werkzeug Authorization object) and everything else is code you write or an extension
you install. Four mechanisms cover almost every API, and they can be combined on different routes.
| Mechanism | Who it is for | Strength | Weakness |
|---|---|---|---|
Session cookie (Flask-Login) |
Browsers talking to the same site |
Server-side revocation is trivial, no token handling in JavaScript |
Needs CSRF protection, awkward for non-browser clients |
Signed token (JWT) |
SPAs, mobile apps, service-to-service calls |
Stateless verification, claims carry roles and scopes, works across services |
Cannot be revoked before it expires unless you add state; the token is a bearer credential |
API key |
Programs owned by a developer or a partner |
Simple, long-lived, easy to revoke and scope |
A leaked key works until revoked; identifies an application rather than a person |
HTTP Basic |
Internal tools, health checks, quick prototypes |
Supported by every HTTP client and by browsers |
Sends the password on every request, so it needs TLS everywhere and no way to log out |
The decision flows from who the client is and whether the identity comes from your own database or from an external identity provider.
Flask-JWT-Extended Basics
A JSON Web Token (JWT, RFC 7519) is three Base64URL parts separated by dots: a header that names the signing algorithm,
a payload of claims (sub the subject, exp the expiry, iat the issue time, jti a unique id, plus your own) and a
signature. The signature proves the payload was produced by the holder of the key and has not been changed; it does
not hide the payload, which anyone can decode, so never put secrets in a token.
Flask-JWT-Extended wraps PyJWT for Flask. Its JWTManager registers error handlers and configuration defaults,
create_access_token() and create_refresh_token() issue tokens, and the @jwt_required() decorator verifies the
token in the request before the view runs. Inside the view, get_jwt_identity() returns the sub claim and get_jwt()
returns the whole payload. The decorator is always called with parentheses in version 4: @jwt_required without them
is the version 3 form that several tutorials and one of the reference books still use.
The first example is a complete login-and-protect cycle. The password is checked with the Werkzeug hash functions already used in the authentication page, the token identity is the user id as a string, and the signing key is generated at start-up instead of being written into the source.
import secrets
from datetime import timedelta
from flask import Flask, jsonify, request
from werkzeug.security import check_password_hash, generate_password_hash
from flask_jwt_extended import (
JWTManager,
create_access_token,
create_refresh_token,
current_user,
get_jwt,
get_jwt_identity,
jwt_required,
)
# A throwaway password for the demo; real ones come from the registration form.
DEMO_PASSWORD = secrets.token_urlsafe(12)
USERS = {
"1": {
"id": "1",
"email": "ana@example.com",
"password_hash": generate_password_hash(DEMO_PASSWORD),
"roles": ["reader"],
},
}
# Compared when the e-mail is unknown, so a missing user costs as much time as a wrong password.
DUMMY_HASH = generate_password_hash(secrets.token_urlsafe(12))
REVOKED_JTIS: set[str] = set() # stands in for Redis or a database table, see the revocation section
def create_jwt_app() -> Flask:
app = Flask(__name__)
app.config["JWT_SECRET_KEY"] = secrets.token_hex(32) # in production: read from the environment
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=15)
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=7)
jwt = JWTManager(app)
@jwt.user_lookup_loader
def load_user(jwt_header, jwt_data):
return USERS.get(jwt_data["sub"]) # None makes current_user fail with a 401
@jwt.token_in_blocklist_loader
def is_revoked(jwt_header, jwt_data):
return jwt_data["jti"] in REVOKED_JTIS
@app.post("/api/login")
def login():
data = request.get_json(silent=True) or {}
user = next((u for u in USERS.values() if u["email"] == data.get("email")), None)
stored = user["password_hash"] if user else DUMMY_HASH
if not check_password_hash(stored, data.get("password", "")) or user is None:
return jsonify(error="bad credentials"), 401
return jsonify(
access_token=create_access_token(
identity=user["id"], fresh=True, additional_claims={"roles": user["roles"]}
),
refresh_token=create_refresh_token(identity=user["id"]),
)
@app.get("/api/me")
@jwt_required()
def me():
return jsonify(id=get_jwt_identity(), email=current_user["email"], roles=get_jwt()["roles"])
return app
app = create_jwt_app()
client = app.test_client()
tokens = client.post("/api/login", json={"email": "ana@example.com", "password": DEMO_PASSWORD}).get_json()
print(sorted(tokens))
print(client.get("/api/me").status_code, client.get("/api/me").get_json())
bearer = {"Authorization": f"Bearer {tokens['access_token']}"}
print(client.get("/api/me", headers=bearer).get_json())
print(client.post("/api/login", json={"email": "ana@example.com", "password": secrets.token_urlsafe(8)}).status_code)
Source: Flask-JWT-Extended: Basic Usage
The login answers with an access token and a refresh token (['access_token', 'refresh_token']); the call without a
token is 401 with {'msg': 'Missing Authorization Header'}, and the call with the Authorization: Bearer <token>
header returns the user. Over HTTP the same exchange looks like this (the token is shown as a placeholder because a real
one is different every time):
curl -s -X POST http://localhost:5000/api/login \
-H 'Content-Type: application/json' \
-d '{"email": "ana@example.com", "password": "<password>"}'
curl -s http://localhost:5000/api/me -H 'Authorization: Bearer <access-token>'
Source: Flask-JWT-Extended: Basic Usage
{"email": "ana@example.com", "id": "1", "roles": ["reader"]}
Five details in that example are worth naming.
-
The identity is a string. The
subclaim must be a string according to the JWT specification. PyJWT 2.10 started enforcing it when decoding, and recent Flask-JWT-Extended 4 releases pass that on (theJWT_VERIFY_SUBoption, on by default):create_access_token(identity=user.id)with an integer id still creates a token, but it is rejected with a422("Subject must be a string") the first time it is used. Passstr(user.id), or register a function with@jwt.user_identity_loaderthat turns your user object into a string. The optionJWT_VERIFY_SUBcan switch the check off, but it exists only for tokens you cannot reissue. -
The decorator is
@jwt_required(). Version 4 requires the parentheses, andoptional=True,fresh=True,refresh=Trueandlocations=[...]are its arguments. -
current_userneeds a loader.@jwt.user_lookup_loaderreceives the header and payload and returns the user object (orNone); the extension then exposes it ascurrent_user. Without the loader only the claims are available. -
The roles are a claim, not a lookup.
additional_claimsembeds data in the payload at issue time. Keep it small, and remember that it stays as it was issued until the token expires (see the authorization page for the consequences). -
The comparison against a dummy hash keeps the response time of an unknown e-mail close to that of a wrong password, so the endpoint does not reveal which e-mails are registered. Error messages are identical for both cases for the same reason.
Configuration Reference
Flask-JWT-Extended reads its options from app.config, all prefixed with JWT_. If JWT_SECRET_KEY is not set it falls
back to Flask’s SECRET_KEY, but a dedicated key means rotating one does not log everybody out of the other.
| Option | Default | Purpose |
|---|---|---|
|
|
HMAC key for HS256, HS384 and HS512. Generate it with |
|
|
Signing algorithm. Use |
|
|
PEM keys for the asymmetric algorithms; a verifying service needs only the public key. |
|
|
Algorithms accepted when verifying. Keep the list as short as possible. |
|
15 minutes |
Lifetime of access tokens; a |
|
30 days |
Lifetime of refresh tokens. |
|
|
Where to look for the token: |
|
|
The header and scheme read when |
|
|
Send the cookie only over HTTPS. Set it to |
|
|
The |
|
|
Double-submit CSRF protection for token cookies and the methods it applies to. |
|
|
Header in which the client echoes the CSRF value. |
|
|
Required |
|
|
Seconds of tolerated clock skew for |
|
|
Claim that carries the identity and whether it must be a string. |
|
|
Adds a "not before" claim to every token. |
|
|
Key of the error message in the default JSON error bodies. |
Error Responses
By default the extension answers 401 for a missing, expired or revoked token and 422 for a token it cannot parse or
whose claims do not validate, each with a small JSON body under the msg key. Clients are simpler to write when every
authentication failure is the same status and shape, and the specification for bearer tokens (RFC 6750) asks for a
WWW-Authenticate header on 401 responses. The JWTManager has one decorator per failure so you can replace them.
from flask import make_response
def create_json_error_app() -> Flask:
app = create_jwt_app()
jwt = app.extensions["flask-jwt-extended"]
def failure(code: str, status: int = 401):
body = make_response(jsonify(error=code), status)
body.headers["WWW-Authenticate"] = f'Bearer error="{code}"'
return body
@jwt.unauthorized_loader
def missing_token(reason): # no Authorization header at all
response = jsonify(error="authentication_required")
response.status_code = 401
response.headers["WWW-Authenticate"] = "Bearer"
return response
@jwt.invalid_token_loader
def invalid_token(reason): # malformed, wrong signature, wrong type
return failure("invalid_token")
@jwt.expired_token_loader
def expired_token(jwt_header, jwt_data):
return failure("token_expired")
@jwt.revoked_token_loader
def revoked_token(jwt_header, jwt_data):
return failure("token_revoked")
return app
app = create_json_error_app()
client = app.test_client()
with app.app_context():
expired = create_access_token(identity="1", expires_delta=timedelta(seconds=-1), additional_claims={"roles": []})
print(client.get("/api/me").get_json())
print(client.get("/api/me", headers={"Authorization": "Bearer not-a-token"}).status_code)
response = client.get("/api/me", headers={"Authorization": f"Bearer {expired}"})
print(response.status_code, response.headers["WWW-Authenticate"])
The unknown token is 401 instead of 422, the expired token carries Bearer error="token_expired", and the client can
read the error code to decide whether to refresh. The manager is stored in app.extensions["flask-jwt-extended"],
which is where the init_app pattern of every extension keeps it.
Access Tokens, Refresh Tokens and Freshness
A short-lived access token limits the damage of a leak, but nobody wants to type a password every fifteen minutes. The usual answer is a second, long-lived token: the refresh token. It can only be used at one endpoint, which exchanges it for a new access token.
Three properties of this design matter in practice.
-
Token type is checked.
@jwt_required()accepts only access tokens and@jwt_required(refresh=True)only refresh tokens, so a stolen refresh token cannot call the API and an access token cannot mint new ones. -
Freshness separates "logged in a moment ago" from "has a valid token". A token created by a password login is marked
fresh=True; tokens created from a refresh token are not (create_access_token(identity, fresh=False), the default). Sensitive operations such as changing the password can demand@jwt_required(fresh=True), and the client falls back to the login form when it gets the "Fresh token required" answer. Thefreshargument also accepts atimedelta, which makes the freshness expire on its own. -
Rotation turns reuse into a signal. Issuing a new refresh token on every refresh and revoking the old one means a refresh token is valid exactly once. If an attacker and the real client both try to use the same token, the second attempt fails, which is a reason to revoke the whole session.
def create_refresh_app() -> Flask:
app = create_json_error_app()
@app.post("/api/refresh")
@jwt_required(refresh=True)
def refresh():
claims = get_jwt()
REVOKED_JTIS.add(claims["jti"]) # a refresh token is used once
identity = get_jwt_identity()
user = USERS[identity]
return jsonify(
access_token=create_access_token(identity=identity, additional_claims={"roles": user["roles"]}),
refresh_token=create_refresh_token(identity=identity),
)
@app.post("/api/password")
@jwt_required(fresh=True)
def change_password():
return jsonify(changed=True)
@app.post("/api/logout")
@jwt_required(verify_type=False) # accepts an access or a refresh token
def logout():
REVOKED_JTIS.add(get_jwt()["jti"])
return jsonify(logged_out=True)
return app
app = create_refresh_app()
client = app.test_client()
tokens = client.post("/api/login", json={"email": "ana@example.com", "password": DEMO_PASSWORD}).get_json()
access = {"Authorization": f"Bearer {tokens['access_token']}"}
refresh = {"Authorization": f"Bearer {tokens['refresh_token']}"}
print("refresh token on a normal endpoint:", client.get("/api/me", headers=refresh).status_code)
print("access token on the refresh endpoint:", client.post("/api/refresh", headers=access).status_code)
rotated = client.post("/api/refresh", headers=refresh).get_json()
print("second use of the same refresh token:", client.post("/api/refresh", headers=refresh).status_code)
stale = {"Authorization": f"Bearer {rotated['access_token']}"}
print("password change with a refreshed token:", client.post("/api/password", headers=stale).status_code)
print("password change with a fresh token:", client.post("/api/password", headers=access).status_code)
client.post("/api/logout", headers=access)
print("after logout:", client.get("/api/me", headers=access).get_json())
Using a refresh token on an ordinary endpoint or an access token on the refresh endpoint is rejected (422 with the
default handlers, 401 with the ones installed above), the second use of a rotated refresh token is rejected as revoked,
a token obtained by refreshing cannot change the password, and a logged-out token answers token_revoked.
Revocation and the Blocklist
A signed token is valid until it expires; nothing on the server needs to remember it, which is the whole advantage and
also the problem. To log a user out, ban an account or react to a leaked token you need a list of identifiers that must
be refused. Flask-JWT-Extended calls it a blocklist: @jwt.token_in_blocklist_loader receives every decoded token and
returns True for the ones to refuse, and the jti claim (a unique id generated for every token) is the key to store.
The example above used a Python set so it can run on its own. In production that is wrong for two reasons: it is lost on restart and it is not shared between workers. Keep the identifiers in Redis with an expiry equal to the token’s remaining lifetime, so the list never grows beyond the number of tokens that could still be valid (see the NoSQL, Redis, MongoDB and Search page of this section), or in a database table that a periodic job prunes. Every authenticated request now pays one extra lookup, which is why many applications revoke refresh tokens (rare) and let short-lived access tokens simply expire.
The alternatives are all state in disguise. A per-user "tokens valid after" timestamp stored with the user and compared
with iat revokes every token of one user with one write. Opaque tokens and introspection (see
opaque tokens, introspection and revocation) move
the check to the issuer on every call. Whichever you choose, the lifetime of the access token is the maximum time a
revoked permission keeps working, so choose it with that in mind.
Tokens in Cookies and the CSRF Double-Submit
Storing a token in localStorage or sessionStorage makes it readable by any script that runs on the page, including
one injected through a cross-site scripting hole. An HttpOnly cookie is invisible to JavaScript, which is the stronger
default for browser clients, but a cookie is also sent automatically by the browser, which brings back the cross-site
request forgery problem that bearer headers do not have. Flask-JWT-Extended handles both with the
double-submit technique: next to the token cookie it sets a second, JavaScript-readable CSRF cookie whose value is also
embedded in the token. A state-changing request must echo that value in the X-CSRF-TOKEN header; a forged request from
another site cannot read the cookie, so it cannot send the header, and the server compares the two.
from flask_jwt_extended import set_access_cookies, unset_jwt_cookies
def create_cookie_app() -> Flask:
app = Flask(__name__)
app.config.update(
JWT_SECRET_KEY=secrets.token_hex(32),
JWT_TOKEN_LOCATION=["cookies"],
JWT_COOKIE_SECURE=False, # True in production, where the site is served over HTTPS
JWT_COOKIE_SAMESITE="Strict",
JWT_COOKIE_CSRF_PROTECT=True,
)
JWTManager(app)
@app.post("/spa/login")
def login():
response = jsonify(logged_in=True)
set_access_cookies(response, create_access_token(identity="1")) # sets the token and the CSRF cookies
return response
@app.post("/spa/logout")
def logout():
response = jsonify(logged_out=True)
unset_jwt_cookies(response)
return response
@app.get("/spa/shelves")
@jwt_required()
def list_shelves():
return jsonify(shelves=["to-read"])
@app.post("/spa/shelves")
@jwt_required()
def create_shelf():
return jsonify(created=True), 201
return app
app = create_cookie_app()
client = app.test_client()
login_response = client.post("/spa/login")
print([cookie.split("=")[0] for cookie in login_response.headers.getlist("Set-Cookie")])
csrf = client.get_cookie("csrf_access_token").value
print("GET without a header:", client.get("/spa/shelves").status_code)
print("POST without the CSRF header:", client.post("/spa/shelves").get_json())
print("POST with the CSRF header:", client.post("/spa/shelves", headers={"X-CSRF-TOKEN": csrf}).status_code)
The login sets two cookies, access_token_cookie (HttpOnly) and csrf_access_token (readable). Reading data is
allowed with the cookie alone, a POST without the header is rejected with {'msg': 'Missing CSRF token'}, and the
same POST with the header succeeds. Only the methods listed in JWT_CSRF_METHODS are checked, which is why GET handlers
must never change state. The cookie options matter as much as the token: JWT_COOKIE_SECURE=True in production,
JWT_COOKIE_SAMESITE as strict as the front end allows (a front end on another site needs "None" together with
Secure, which weakens the protection and makes the double-submit check essential), and short lifetimes. The complete
treatment of cookie flags and CSRF is in the security considerations page of this section, and the trade-offs between
cookies and header tokens for single-page applications continue in the SPA and CORS page.
A single application can accept both: JWT_TOKEN_LOCATION=["headers", "cookies"] lets the browser use cookies and
scripts use the Authorization header, and @jwt_required(locations=["headers"]) restricts one endpoint. CSRF
checking applies only to the token that arrived in a cookie.
Asymmetric Signing with RS256
HS256 uses one shared secret for signing and verifying, so every service that can verify a token can also forge one. That is acceptable inside a single application and a poor fit for a system of services. With RS256 (RSA with SHA-256) the issuer signs with a private key and everybody else verifies with the public key; the public key can be published without risk. ES256 (elliptic curves) works the same way with smaller keys and signatures.
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
def make_rsa_pems() -> tuple[bytes, bytes]:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
public = key.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
return private, public
private_pem, public_pem = make_rsa_pems() # in production: generated once and kept in a secret store
issuer_app = Flask("issuer")
issuer_app.config.update(JWT_ALGORITHM="RS256", JWT_PRIVATE_KEY=private_pem, JWT_PUBLIC_KEY=public_pem)
JWTManager(issuer_app)
verifier_app = Flask("verifier") # a second service: it has no private key at all
verifier_app.config.update(JWT_ALGORITHM="RS256", JWT_PUBLIC_KEY=public_pem)
JWTManager(verifier_app)
@verifier_app.get("/ping")
@jwt_required()
def ping():
return jsonify(caller=get_jwt_identity())
with issuer_app.app_context():
issued = create_access_token(identity="1")
print(verifier_app.test_client().get("/ping", headers={"Authorization": f"Bearer {issued}"}).get_json())
with verifier_app.app_context():
try:
create_access_token(identity="1")
except RuntimeError as error:
print("the verifier cannot issue tokens:", error)
The verifier accepts the token and refuses to create one, because JWT_PRIVATE_KEY is not set there. The private key
belongs in a secret manager or an environment variable and is never committed; the pair above is generated in memory
only to keep the example self-contained. The cryptography package is required for any RSA or elliptic-curve
algorithm (pip install "pyjwt[crypto]" or pip install cryptography).
Validating Tokens from an Identity Provider
When the application delegates login to an identity provider (Auth0, Keycloak, Microsoft Entra ID, Okta, Google, or a
self-hosted OAuth server; the Authlib and Flask-Dance side is on the
OAuth social login and LDAP page), the Flask API is a resource
server: it never issues tokens, it only has to decide whether the one in the request is genuine and meant for it. The
provider publishes its public keys as a JSON Web Key Set (JWKS) at a well-known URL, found through its discovery
document (see
discovery, metadata and client registration). Each
token header carries a kid (key id) that selects the key, which is what makes key rotation possible without redeploying
the API.
PyJWT does the work with two calls. PyJWKClient(uri) downloads and caches the key set and
get_signing_key_from_jwt(token) picks the key named by the token’s kid; jwt.decode() then verifies the signature
and the claims. The validation is only as good as the arguments:
| Argument | Why it is mandatory |
|---|---|
|
The token header names its own algorithm, and trusting it is the classic attack: a token declared |
|
The |
|
The |
|
By default PyJWT validates |
|
A few seconds of tolerance for clock differences between the provider and your server; no more. |
The example below is runnable without any external service: a tiny identity provider is simulated by a local HTTP server that publishes a JWKS document on the loopback interface, and tokens are signed with its key. The Flask API only knows the JWKS URL, the issuer and its own audience, exactly like a real resource server.
import base64
import hashlib
import hmac
import json
import threading
from functools import wraps
from http.server import BaseHTTPRequestHandler, HTTPServer
import time
import jwt
from flask import abort, g
from jwt.algorithms import RSAAlgorithm
ISSUER = "https://idp.example.test/"
AUDIENCE = "https://api.bookshelf.example.test"
idp_private_pem, idp_public_pem = make_rsa_pems()
idp_private_key = serialization.load_pem_private_key(idp_private_pem, password=None)
jwk = RSAAlgorithm.to_jwk(idp_private_key.public_key(), as_dict=True) | {"kid": "key-1", "use": "sig", "alg": "RS256"}
JWKS_BODY = json.dumps({"keys": [jwk]}).encode()
class JwksHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(JWKS_BODY)
def log_message(self, *args): # keep the example output quiet
pass
jwks_server = HTTPServer(("127.0.0.1", 0), JwksHandler)
threading.Thread(target=jwks_server.serve_forever, daemon=True).start()
JWKS_URL = f"http://127.0.0.1:{jwks_server.server_port}/.well-known/jwks.json"
jwks_client = jwt.PyJWKClient(JWKS_URL, cache_keys=True, lifespan=300)
def verify_access_token(token: str) -> dict:
signing_key = jwks_client.get_signing_key_from_jwt(token) # chooses the key by the header's kid
return jwt.decode(
token,
signing_key,
algorithms=["RS256"],
audience=AUDIENCE,
issuer=ISSUER,
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
leeway=30,
)
def external_token_required(scope: str | None = None):
def decorator(view):
@wraps(view)
def wrapped(*args, **kwargs):
header = request.authorization
if header is None or header.type != "bearer" or not header.token:
return jsonify(error="authentication_required"), 401, {"WWW-Authenticate": "Bearer"}
try:
g.claims = verify_access_token(header.token)
except jwt.PyJWTError as error: # signature, exp, aud, iss, alg, unknown kid, JWKS down
return jsonify(error="invalid_token"), 401, {"WWW-Authenticate": 'Bearer error="invalid_token"'}
if scope and scope not in g.claims.get("scope", "").split():
abort(403)
return view(*args, **kwargs)
return wrapped
return decorator
resource_app = Flask("resource-server")
@resource_app.get("/api/shelves")
@external_token_required(scope="shelves:read")
def shelves():
return jsonify(user=g.claims["sub"], audience=g.claims["aud"])
def issue_idp_token(**overrides) -> str:
now = int(time.time())
claims = {"iss": ISSUER, "aud": AUDIENCE, "sub": "auth0|ana", "iat": now, "exp": now + 300, "scope": "shelves:read"}
claims.update(overrides)
return jwt.encode(claims, idp_private_pem, algorithm="RS256", headers={"kid": "key-1"})
client = resource_app.test_client()
def call(token: str):
response = client.get("/api/shelves", headers={"Authorization": f"Bearer {token}"})
return response.status_code, response.get_json()
print("valid:", call(issue_idp_token()))
print("wrong audience:", call(issue_idp_token(aud="https://another-api.example.test"))[0])
print("wrong issuer:", call(issue_idp_token(iss="https://evil.example.test/"))[0])
print("expired:", call(issue_idp_token(exp=int(time.time()) - 3600))[0])
print("missing scope:", call(issue_idp_token(scope="shelves:write"))[0])
The valid token is accepted and the wrong audience, wrong issuer and expired tokens are rejected with 401, while a
correctly signed token lacking the scope gets 403. The two classic attacks are worth running against the same
validator.
def b64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def forged_hs256_token() -> str:
"""Signs with the PUBLIC key as an HMAC secret (algorithm confusion)."""
now = int(time.time())
header = b64(json.dumps({"alg": "HS256", "typ": "JWT", "kid": "key-1"}).encode())
payload = b64(json.dumps({"iss": ISSUER, "aud": AUDIENCE, "sub": "attacker", "iat": now, "exp": now + 300}).encode())
signature = hmac.new(idp_public_pem, f"{header}.{payload}".encode(), hashlib.sha256).digest()
return f"{header}.{payload}.{b64(signature)}"
unsigned = jwt.encode(
{"iss": ISSUER, "aud": AUDIENCE, "sub": "attacker", "iat": int(time.time()), "exp": int(time.time()) + 300},
key=None,
algorithm="none",
headers={"kid": "key-1"},
)
print("HS256 signed with the public key:", call(forged_hs256_token())[0])
print("alg none:", call(unsigned)[0])
try:
verify_access_token(forged_hs256_token())
except jwt.PyJWTError as error:
print(type(error).__name__)
Source: PyJWT: Usage Examples
Both forgeries answer 401; PyJWT reports InvalidAlgorithmError for the first because HS256 is not in the allowed
list. Three further rules complete the validation.
-
Verify, then decode. Never read claims with
jwt.decode(token, options={"verify_signature": False})and make decisions on them; that call is for logging and debugging only. -
Bad
kidand unreachable provider are failures. An unknownkidraises aPyJWKClientErrorand a network failure aPyJWKClientConnectionError; both arePyJWTErrorsubclasses, so the decorator above answers401.PyJWKClientcaches the key set (five minutes by default) and refetches when it meets an unknownkid, subject to a cooldown, which is how provider key rotation is picked up. Fetch keys from the configured URL only: never from ajkuorx5uURL inside the token, which the attacker controls. -
Access tokens are not ID tokens. An API validates access tokens issued for it; the OpenID Connect ID token is meant for the client application (see ID tokens versus access tokens). Some providers issue opaque access tokens that cannot be validated locally; those need introspection instead.
The validation in the example must be exactly what the provider’s documentation says it is, and the profile written down
in RFC 8725, the JWT best current practices, is a good checklist: explicit algorithms, mandatory iss and aud, no
trust in header-supplied keys.
API Keys
A JWT identifies a person for a few minutes. A program that runs for months, owned by a developer or a partner, is
better served by an API key: a long random string issued once, sent in a header such as X-API-Key, and looked up on the
server. There is no standard for them, so the design is yours, and a safe one is small.
-
Generate with
secrets. At least 32 random bytes fromsecrets.token_urlsafe(32), never derived from the user’s data and never guessable. -
Show it once, store a hash. The database keeps a digest, so a database leak does not leak working keys. A plain SHA-256 is enough here, unlike for passwords, because the key already has 256 bits of entropy and cannot be brute-forced; slow password hashes only add latency.
-
Store a non-secret prefix next to the hash. It lets the server find the record with one indexed lookup, lets users recognise the key in a list and lets support staff match a key in a log without ever seeing the secret.
-
Compare in constant time with
hmac.compare_digest. -
Attach scopes, an owner, a creation date, a last-used date and an expiry, and let the owner revoke it at any time.
-
Use headers only, never the query string, which ends up in logs and browser history.
API_KEYS: dict[str, dict] = {} # a database table in production: prefix, digest, owner, scopes, revoked
def issue_api_key(owner: str, scopes: set[str]) -> str:
prefix = secrets.token_hex(4)
secret = secrets.token_urlsafe(32)
API_KEYS[prefix] = {
"digest": hashlib.sha256(secret.encode()).digest(),
"owner": owner,
"scopes": scopes,
"revoked": False,
}
return f"bk_{prefix}.{secret}" # returned once and never stored in clear
def api_key_required(scope: str):
def decorator(view):
@wraps(view)
def wrapped(*args, **kwargs):
presented = request.headers.get("X-API-Key", "")
prefix, _, secret = presented.removeprefix("bk_").partition(".")
record = API_KEYS.get(prefix)
digest = hashlib.sha256(secret.encode()).digest()
if record is None or record["revoked"] or not hmac.compare_digest(digest, record["digest"]):
return jsonify(error="invalid_api_key"), 401
if scope not in record["scopes"]:
return jsonify(error="insufficient_scope"), 403
g.api_key_owner = record["owner"]
return view(*args, **kwargs)
return wrapped
return decorator
keys_app = Flask("keys")
@keys_app.get("/partner/books")
@api_key_required("books:read")
def partner_books():
return jsonify(owner=g.api_key_owner)
reader_key = issue_api_key("acme-sync", {"books:read"})
client = keys_app.test_client()
print(client.get("/partner/books", headers={"X-API-Key": reader_key}).get_json())
print(client.get("/partner/books").status_code, client.get("/partner/books", headers={"X-API-Key": reader_key[:-3] + "xyz"}).status_code)
API_KEYS[reader_key.removeprefix("bk_").split(".")[0]]["revoked"] = True
print("after revocation:", client.get("/partner/books", headers={"X-API-Key": reader_key}).status_code)
Source: Flask: View Decorators
An unknown or revoked key is 401, a valid key without the scope would be 403. Rotation means issuing a second key,
letting the client switch and revoking the first. If the same endpoints must accept API keys or user tokens, write one
decorator that tries each scheme and sets g in the same way, so the view code does not care which was used. The
Python secrets module is documented at docs.python.org.
HTTP Basic Authentication
HTTP Basic (RFC 7617) sends username:password Base64-encoded in the Authorization header of every request. That is
not encryption, so it is acceptable only over HTTPS, and it has no expiry, no logout and no scopes. It remains useful for
internal endpoints, for scrapers and monitoring probes, and because every HTTP client and every browser supports it.
Flask exposes the parsed header as request.authorization: an object whose type is "basic" and which has username
and password attributes (the Werkzeug Authorization class).
from werkzeug.datastructures import Authorization
def basic_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
auth = request.authorization
user = None
if auth is not None and auth.type == "basic":
user = next((u for u in USERS.values() if u["email"] == auth.username), None)
stored = user["password_hash"] if user else DUMMY_HASH
if not check_password_hash(stored, auth.password or "") or user is None:
user = None
if user is None:
response = jsonify(error="authentication_required")
response.status_code = 401
response.headers["WWW-Authenticate"] = 'Basic realm="Bookshelf", charset="UTF-8"'
return response
g.user = user
return view(*args, **kwargs)
return wrapped
basic_app = Flask("basic")
@basic_app.get("/internal/stats")
@basic_required
def stats():
return jsonify(user=g.user["email"], books=3)
client = basic_app.test_client()
anonymous = client.get("/internal/stats")
print(anonymous.status_code, anonymous.headers["WWW-Authenticate"])
print(client.get("/internal/stats", auth=("ana@example.com", DEMO_PASSWORD)).get_json())
print(client.get("/internal/stats", auth=Authorization("basic", {"username": "ana@example.com", "password": secrets.token_urlsafe(8)})).status_code)
Source: Werkzeug: Authorization
The WWW-Authenticate: Basic header on the 401 is what makes a browser show its login prompt; a custom scheme name
avoids the prompt for AJAX clients. In the test client, auth=("user", "password") builds the header.
The Flask-HTTPAuth extension packages exactly this pattern (HTTPBasicAuth, HTTPTokenAuth, MultiAuth, roles and
a current_user() helper) and is a reasonable choice when you want Basic and token schemes side by side. Its last release
is from 2023 and it is stable rather than actively developed, so check its status before adding it to a new project.
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
basic = HTTPBasicAuth(realm="Bookshelf")
header_key = HTTPTokenAuth(scheme="Bearer", header="X-API-Key")
shared_key = secrets.token_urlsafe(32)
@basic.verify_password
def verify_password(username, password):
user = next((u for u in USERS.values() if u["email"] == username), None)
if user and check_password_hash(user["password_hash"], password):
return user["email"]
return None
@header_key.verify_token
def verify_token(token):
return "robot" if hmac.compare_digest(token, shared_key) else None
httpauth_app = Flask("httpauth")
@httpauth_app.get("/hello")
@basic.login_required
def hello():
return jsonify(user=basic.current_user())
@httpauth_app.get("/robot")
@header_key.login_required
def robot():
return jsonify(user=header_key.current_user())
client = httpauth_app.test_client()
print(client.get("/hello").status_code, client.get("/hello", auth=("ana@example.com", DEMO_PASSWORD)).get_json())
print(client.get("/robot", headers={"X-API-Key": shared_key}).get_json(), client.get("/robot").status_code)
Source: Flask-HTTPAuth documentation
Guarding a Whole Blueprint
Decorating every view invites the one view that was forgotten. Authentication that applies to an entire API can be
installed once as a before_request hook of the blueprint (the same mechanism as in the hooks page of this section) so
that every route registered later is protected by default. The hook is a function that returns None to let the request
continue or a response to stop it.
The hook has one trap, and it concerns the browser. Before a cross-origin request that carries an Authorization header
or a JSON body, the browser sends a preflight: an OPTIONS request to the same URL that asks the server which
methods and headers are allowed. The preflight never carries the credentials, because the browser sends it precisely to
find out whether it may. A guard that rejects every request without a token therefore rejects every preflight, and the
browser reports a CORS failure even though the API itself is healthy. Flask answers OPTIONS automatically for every
route, and Flask-CORS adds the Access-Control-* headers to that answer, but only if the request reaches it.
from flask import Blueprint
from flask_cors import CORS
from flask_jwt_extended import verify_jwt_in_request
SPA_ORIGIN = "https://app.bookshelf.example.test"
def create_guarded_app(skip_preflight: bool) -> Flask:
app = Flask(__name__)
app.config["JWT_SECRET_KEY"] = secrets.token_hex(32)
JWTManager(app)
CORS(app, origins=[SPA_ORIGIN], allow_headers=["Authorization", "Content-Type"], max_age=600)
api = Blueprint("api", __name__, url_prefix="/api")
@api.before_request
def require_token():
if skip_preflight and request.method == "OPTIONS":
return None # the preflight carries no credentials by design
if "Authorization" not in request.headers:
return jsonify(error="authentication_required"), 401
verify_jwt_in_request() # raises the error that JWTManager turns into a 401
@api.get("/books")
def books():
return jsonify(books=[], user=get_jwt_identity())
app.register_blueprint(api)
return app
preflight_headers = {
"Origin": SPA_ORIGIN,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization",
}
broken = create_guarded_app(skip_preflight=False).test_client().options("/api/books", headers=preflight_headers)
fixed = create_guarded_app(skip_preflight=True).test_client().options("/api/books", headers=preflight_headers)
print("guard rejects the preflight:", broken.status_code)
print("guard skips the preflight:", fixed.status_code, fixed.headers["Access-Control-Allow-Origin"], fixed.headers["Access-Control-Allow-Headers"])
Source: Flask-CORS documentation
The broken guard answers the preflight with 401 and no CORS headers; the fixed one answers 200 with the allowed origin
and headers. The extension’s verify_jwt_in_request() and @jwt_required() themselves already skip OPTIONS requests
(the extension treats OPTIONS as an exempt method), which is why the guard above performs its own header check first;
the trap appears with hand-written guards, with before_request hooks that check credentials themselves and with API
gateways. The same rule applies to any middleware that sits before CORS handling. Everything about origins,
credentials and preflight caching continues in the SPA and CORS page of this section, and the protocol itself is
explained on CORS.
Authorization Is Not Authentication
A valid token proves who is calling; it says nothing about which resources that person may touch. The most common
vulnerability of APIs (OWASP API1: broken object-level authorization) is a valid, authenticated user changing the number
in /api/shelves/17 to read somebody else’s shelf. Every endpoint that takes an identifier must check ownership, and the
cheapest correct way is to put the owner in the query, so a foreign object looks exactly like a missing one.
SHELVES = {17: {"id": 17, "owner": "1", "name": "to-read"}, 18: {"id": 18, "owner": "2", "name": "private"}}
owner_app = create_json_error_app()
@owner_app.get("/api/shelves/<int:shelf_id>")
@jwt_required()
def get_shelf(shelf_id: int):
shelf = SHELVES.get(shelf_id)
if shelf is None or shelf["owner"] != get_jwt_identity():
abort(404) # 404, not 403: do not reveal that the shelf exists
return jsonify(shelf)
client = owner_app.test_client()
tokens = client.post("/api/login", json={"email": "ana@example.com", "password": DEMO_PASSWORD}).get_json()
bearer = {"Authorization": f"Bearer {tokens['access_token']}"}
print(client.get("/api/shelves/17", headers=bearer).status_code, client.get("/api/shelves/18", headers=bearer).status_code)
Source: Flask-JWT-Extended: Basic Usage
Roles, permissions, scopes and policies, and the choice between 403 and 404, are developed in
Authorization & Roles; this page only supplies the identity that those
checks rely on.
OpenAPI Security Schemes and Swagger UI
An API documented with OpenAPI (see the OpenAPI page of this section) should say how it is authenticated, so that
Swagger UI shows an Authorize button and the generated clients send the right header. In OpenAPI 3.x that is a
security scheme declared in the components and referenced by the operations that need it. flask-smorest builds its
specification with apispec, whose components.security_scheme() registers the scheme, and @blp.doc(security=...)
attaches it to a view; the JWT itself is still verified by Flask-JWT-Extended.
from flask_smorest import Api, Blueprint as SmorestBlueprint
doc_app = create_json_error_app()
doc_app.config.update(
API_TITLE="Bookshelf API",
API_VERSION="v1",
OPENAPI_VERSION="3.1.0",
OPENAPI_URL_PREFIX="/",
OPENAPI_JSON_PATH="openapi.json",
OPENAPI_SWAGGER_UI_PATH="/docs",
OPENAPI_SWAGGER_UI_URL="https://cdn.jsdelivr.net/npm/swagger-ui-dist/",
)
api = Api(doc_app)
api.spec.components.security_scheme("bearerAuth", {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"})
shelves_blp = SmorestBlueprint("shelves", __name__, url_prefix="/v1/shelves", description="Shelves")
@shelves_blp.route("/")
@shelves_blp.doc(security=[{"bearerAuth": []}])
@jwt_required()
def list_my_shelves():
return jsonify([s for s in SHELVES.values() if s["owner"] == get_jwt_identity()])
api.register_blueprint(shelves_blp)
spec = doc_app.test_client().get("/openapi.json").get_json()
print(spec["components"]["securitySchemes"])
print(spec["paths"]["/v1/shelves/"]["get"]["security"])
The specification now contains the bearerAuth scheme and the operation’s security requirement, and Swagger UI shows
the Authorize dialog in which a developer pastes a token. For OAuth 2.0 providers the scheme type is oauth2 (with
flows) or openIdConnect (with the discovery URL), and Swagger UI can then run the authorization-code flow with PKCE
itself, so the developer logs in at the identity provider instead of pasting a token; the flows are described in
authorization code and PKCE and
client credentials. Both flows are the ones
current for new APIs: the implicit and resource-owner password grants that older material uses are deprecated (see
the legacy grants). The scheme and the security
requirement are documentation and tooling hints; they do not enforce anything, so the view still needs its own
@jwt_required().
Testing Token Authentication
Every example on this page is its own test: it builds the app, obtains a token the same way a client would and sends it
through app.test_client(). A few habits keep such tests short and robust.
-
Mint tokens directly in tests. Inside
app.app_context(),create_access_token()produces a token without going through the login endpoint, andexpires_delta=timedelta(seconds=-1)produces an expired one for free. -
Test the negative cases as seriously as the positive. Missing header, malformed token, expired token, wrong audience, wrong algorithm, revoked token, token of another user, missing scope: each is one short test and each is a vulnerability when it fails.
-
Use a random key per test app (
secrets.token_hex(32)in the fixture), so a test cannot depend on a constant that might also end up in production configuration. -
Do not mock the verification. Replacing
verify_jwt_in_requestin tests proves nothing about the real configuration; sign real tokens with the test key instead. -
Cookie tests use
client.get_cookie("csrf_access_token")to read the CSRF value and send it in the header, as the cookie example shows.
Pitfalls and Security
-
A secret in the source. The signing key, the RSA private key and API key pepper values never belong in the repository, in an image or in a test fixture shared with production. Generate with
secrets, inject through the environment or a secret manager, and rotate: with HS256 a rotation invalidates all tokens, with RS256 and a JWKS you can publish the new key next to the old one. -
Long-lived access tokens. The default is fifteen minutes, and that is already generous for most APIs. A token that lives for days is a password that cannot be changed.
-
Roles and permissions embedded in tokens. They go stale until expiry. Keep them coarse, or look them up per request for sensitive actions.
-
Tokens in
localStorage. Any XSS reads them. PreferHttpOnlycookies with CSRF protection for browser clients, and a strict content security policy in either case. -
Trusting the header. Never accept the algorithm, the key or the key location named by the token. List the algorithms, fix the JWKS URL.
-
Skipping
audandiss. The most common cause of tokens being accepted by the wrong API. -
Reusing one key for access and refresh tokens, or one token for all clients. Separate audiences and lifetimes limit the blast radius of a leak.
-
Logging tokens and keys. Authorization headers, cookies and API keys must be filtered from access logs, error reports and tracing; Flask’s debug output and tracebacks can include request data, and the debugger must never run in production (see the security page of this section).
-
No rate limit on login and refresh. Brute force and credential stuffing target exactly these endpoints; add Flask-Limiter (see the extensions page of this section) and lock out on repeated failures without revealing which part failed.
-
Plain HTTP. Bearer tokens, API keys and Basic credentials are all replayable by whoever can read the traffic; serve the API over HTTPS only and send HSTS.
-
An ever-growing blocklist. Store each revoked
jtiwith an expiry equal to the token’s remaining life. -
Not validating
expon third-party tokens. PyJWT validates it when present, butoptions={"require": [...]}makes the claim mandatory.
What Changed and Where Older Material Is Outdated
| Item | Current situation |
|---|---|
|
Version 4 requires |
|
Rejected on use since PyJWT 2.10 (late 2024) and recent Flask-JWT-Extended 4 releases, which enforce a string |
Describing the JWT as "the client signs it with the secret key" and "a new token is issued on every request" |
Both are wrong in one reference book’s walk-through. The client never holds the signing key: it only carries a token issued at login or refresh. Refresh tokens exist so that an access token is renewed when it expires, not on every request. |
Storing the token in |
Exposes it to XSS. Cookies with |
A hardcoded |
Never reproduce it. Use |
The OAuth 2.0 implicit and password (ROPC) grants shown in older API material |
Both are deprecated and removed from OAuth 2.1. Use the authorization-code flow with PKCE for users and client credentials for services; see the OAuth section. |
Verifying a token with the algorithm taken from the token header (PyJWT 1.x allowed |
PyJWT 2 requires an explicit |
|
Both are mandatory in RFC 8725 and in every identity provider’s guidance for resource servers. |
Where the Books Are Outdated
The two books that cover this topic are not wrong about the idea, only about its current details. Microservice APIs
(Manning, chapter 11 and appendix C) explains JWT claims, RS256 with PyJWT and cryptography, validating aud and alg,
authentication middleware, CORS preflight requests that bypass authentication and ownership checks very well; this page
reproduces the same ideas with current code, and where its examples use the deprecated implicit and password flows, the
authorization-code flow with PKCE and client credentials replace them. Full-Stack Flask and React (Packt, chapter 12)
uses Flask-JWT-Extended with @jwt_required without parentheses, integer identities, a hardcoded secret key, a
description of the token mechanism with the errors listed in the table above, and localStorage as the storage;
every one of those is corrected here. No text, code or figure from either book is reproduced on this page.
Summary
-
Use a session cookie for browsers on your own site, a JWT for apps and services, an API key for programs and Basic only as a last resort, always over HTTPS.
-
With Flask-JWT-Extended:
JWTManager,create_access_token(identity=str(user.id)),@jwt_required(), short access tokens, refresh tokens with rotation, fresh tokens for sensitive actions, and a blocklist keyed byjtiwhen revocation is needed. -
For browsers prefer
HttpOnlytoken cookies with the CSRF double-submit; for headers, never put tokens in the query string. -
Use RS256 or ES256 when other services verify tokens; never share an HMAC key across trust boundaries.
-
For tokens issued by an identity provider, validate with PyJWT:
PyJWKClient, explicitalgorithms, mandatoryaud,issandexp, and fixed JWKS URL. -
Hash API keys, show them once, compare in constant time and give them scopes and an owner.
-
Guards must let the CORS preflight through; authentication is not authorization, so check ownership on every object.
-
Declare a security scheme in the OpenAPI document so that Swagger UI and clients send the credential.