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.

flowchart TD A[Who calls the API?] --> B{A person in a browser on your own site?} B -- yes --> C[Session cookie with Flask-Login] B -- no --> D{Does an external identity provider issue the tokens?} D -- yes --> E[Validate its JWT with PyJWT and its JWKS] D -- no --> F{A person with an app, or a program?} F -- a person --> G[Your own JWT with Flask-JWT-Extended] F -- a program --> H[API key, or JWT with client credentials]
Figure 1. Choosing how an API caller authenticates

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)

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>'
{"email": "ana@example.com", "id": "1", "roles": ["reader"]}

Five details in that example are worth naming.

  • The identity is a string. The sub claim 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 (the JWT_VERIFY_SUB option, on by default): create_access_token(identity=user.id) with an integer id still creates a token, but it is rejected with a 422 ("Subject must be a string") the first time it is used. Pass str(user.id), or register a function with @jwt.user_identity_loader that turns your user object into a string. The option JWT_VERIFY_SUB can switch the check off, but it exists only for tokens you cannot reissue.

  • The decorator is @jwt_required(). Version 4 requires the parentheses, and optional=True, fresh=True, refresh=True and locations=[...] are its arguments.

  • current_user needs a loader. @jwt.user_lookup_loader receives the header and payload and returns the user object (or None); the extension then exposes it as current_user. Without the loader only the claims are available.

  • The roles are a claim, not a lookup. additional_claims embeds 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

JWT_SECRET_KEY

None (falls back to SECRET_KEY)

HMAC key for HS256, HS384 and HS512. Generate it with secrets.token_hex(32) and read it from the environment.

JWT_ALGORITHM

"HS256"

Signing algorithm. Use "RS256" or "ES256" together with the two key options below when another service must verify tokens without being able to forge them.

JWT_PRIVATE_KEY, JWT_PUBLIC_KEY

None

PEM keys for the asymmetric algorithms; a verifying service needs only the public key.

JWT_DECODE_ALGORITHMS

None (the signing algorithm only)

Algorithms accepted when verifying. Keep the list as short as possible.

JWT_ACCESS_TOKEN_EXPIRES

15 minutes

Lifetime of access tokens; a timedelta, an integer in seconds or False (never expires, avoid).

JWT_REFRESH_TOKEN_EXPIRES

30 days

Lifetime of refresh tokens.

JWT_TOKEN_LOCATION

("headers",)

Where to look for the token: headers, cookies, query_string and json. The query string leaks tokens into logs and the Referer header, so avoid it for anything but one-time links.

JWT_HEADER_NAME, JWT_HEADER_TYPE

"Authorization", "Bearer"

The header and scheme read when headers is a location.

JWT_COOKIE_SECURE

False

Send the cookie only over HTTPS. Set it to True in production.

JWT_COOKIE_SAMESITE

None

The SameSite attribute of the token cookies; "Lax" or "Strict" for same-site front ends.

JWT_COOKIE_CSRF_PROTECT, JWT_CSRF_METHODS

True, ["POST", "PUT", "PATCH", "DELETE"]

Double-submit CSRF protection for token cookies and the methods it applies to.

JWT_ACCESS_CSRF_HEADER_NAME

"X-CSRF-TOKEN"

Header in which the client echoes the CSRF value.

JWT_DECODE_AUDIENCE, JWT_DECODE_ISSUER

None

Required aud and iss claims when verifying; JWT_ENCODE_AUDIENCE and JWT_ENCODE_ISSUER set them when issuing.

JWT_DECODE_LEEWAY

0

Seconds of tolerated clock skew for exp, nbf and iat.

JWT_IDENTITY_CLAIM, JWT_VERIFY_SUB

"sub", True

Claim that carries the identity and whether it must be a string.

JWT_ENCODE_NBF

True

Adds a "not before" claim to every token.

JWT_ERROR_MESSAGE_KEY

"msg"

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.

sequenceDiagram participant C as Client participant A as Flask API participant S as Blocklist C->>A: POST /api/login (email and password) A-->>C: access token (15 min) and refresh token (7 days) C->>A: GET /api/me with the access token A->>S: is the jti revoked? S-->>A: no A-->>C: 200 with the data Note over C,A: the access token expires C->>A: POST /api/refresh with the refresh token A->>S: revoke the old refresh jti A-->>C: new access token and new refresh token C->>A: POST /api/logout A->>S: revoke the current jti A-->>C: 200
Figure 2. A login, an API call, a refresh and a logout with revocation

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. The fresh argument also accepts a timedelta, 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

algorithms=["RS256"]

The token header names its own algorithm, and trusting it is the classic attack: a token declared none has no signature, and one declared HS256 may be signed with the public key used as an HMAC secret. Always pass an explicit, short list of the algorithms your provider uses.

audience="..."

The aud claim says which API the token was issued for. Without the check, a token issued for another API of the same provider is accepted by yours.

issuer="..."

The iss claim must be exactly your provider’s issuer URL, otherwise a token from any other provider that happens to publish a key with the same kid would pass.

options={"require": [...]}

By default PyJWT validates exp, aud and iss only when they are present. Requiring them makes a token that omits them invalid.

leeway=30

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__)

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 kid and unreachable provider are failures. An unknown kid raises a PyJWKClientError and a network failure a PyJWKClientConnectionError; both are PyJWTError subclasses, so the decorator above answers 401. PyJWKClient caches the key set (five minutes by default) and refetches when it meets an unknown kid, subject to a cooldown, which is how provider key rotation is picked up. Fetch keys from the configured URL only: never from a jku or x5u URL 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 from secrets.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)

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)

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)

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"])

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)

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, and expires_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_request in 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. Prefer HttpOnly cookies 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 aud and iss. 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 jti with an expiry equal to the token’s remaining life.

  • Not validating exp on third-party tokens. PyJWT validates it when present, but options={"require": [...]} makes the claim mandatory.

What Changed and Where Older Material Is Outdated

Item Current situation

@jwt_required without parentheses (Flask-JWT-Extended 3.x)

Version 4 requires @jwt_required(). The version 4 upgrade guide lists the renames: @jwt_refresh_token_required became @jwt_required(refresh=True), @fresh_jwt_required became @jwt_required(fresh=True), and the default identity claim is sub instead of identity.

create_access_token(identity=user.id) with an integer

Rejected on use since PyJWT 2.10 (late 2024) and recent Flask-JWT-Extended 4 releases, which enforce a string sub claim. Use str(user.id) or a user_identity_loader.

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 localStorage as the default advice

Exposes it to XSS. Cookies with HttpOnly, SameSite and CSRF double-submit are the safer default for browsers.

A hardcoded secret_key string in the tutorials of several books

Never reproduce it. Use secrets.token_hex() and read it from configuration or the environment.

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 jwt.decode(token, key))

PyJWT 2 requires an explicit algorithms=[...] argument and raises DecodeError without it. Switching verification off is done with options={"verify_signature": False}, only for debugging.

aud and iss checks omitted in examples

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 by jti when revocation is needed.

  • For browsers prefer HttpOnly token 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, explicit algorithms, mandatory aud, iss and exp, 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.