Protecting your API with middleware

Each server SDK has a thin middleware that protects your API with KeenAuth access tokens: net/http in Go, Express and Fastify in Node.js, and Django, Flask and FastAPI in Python. It sits on top of the token verifier and adds no token logic of its own.

What the middleware does

Go (net/http)

The package protect uses only the standard library and works with any router that accepts an http.Handler.

import (
	"github.com/keenauth/keenauth-go/protect"
	"github.com/keenauth/keenauth-go/verify"
)

v, err := verify.New(verify.Config{
	Issuer:   "https://acme.eu.keenauth.app",
	Audience: "https://api.acme.example",
})
if err != nil {
	return err
}
m, err := protect.New(v, protect.Options{
	PublicBaseURL:       "https://api.acme.example",
	ResourceMetadataURL: "https://api.acme.example/.well-known/oauth-protected-resource",
})
if err != nil {
	return err
}

mux := http.NewServeMux()
mux.Handle("GET /orders", m.Require(protect.Requirement{Scopes: []string{"orders:read"}})(http.HandlerFunc(
	func(w http.ResponseWriter, r *http.Request) {
		claims, _ := protect.ClaimsFrom(r.Context())
		fmt.Fprintln(w, claims.Subject)
	})))

age := 10 * time.Minute
mux.Handle("POST /payouts", m.Require(protect.Requirement{
	Scopes:        []string{"payouts:write"},
	AcceptableACR: []string{"urn:idp:acr:mfa"},
	MaxAuthAge:    &age,
})(payoutsHandler))

m.Handler(next) protects a handler with a valid token and no further requirement. Require panics at startup if a scope is not a valid scope token, as http.ServeMux does for an invalid pattern.

Node.js (Express and Fastify)

The middleware is in the @keenauth/node/middleware subpath. Express and Fastify are not dependencies of the SDK.

import { createVerifier } from "@keenauth/node";
import { createProtector, expressAuth, fastifyAuth } from "@keenauth/node/middleware";

const verifier = createVerifier({ issuer: "https://acme.eu.keenauth.app", audience: "https://api.acme.example" });
const protector = createProtector(verifier, { publicBaseUrl: "https://api.acme.example" });

// Express
app.get("/orders", expressAuth(protector, { scopes: ["orders:read"] }), (req, res) => {
  res.json({ sub: req.auth.subject });
});

// Fastify
fastify.decorateRequest("auth", null);
fastify.get("/orders", { preHandler: fastifyAuth(protector, { scopes: ["orders:read"] }) }, async (request) => {
  return { sub: request.auth.subject };
});

Step-up uses { acceptableAcr: ["urn:idp:acr:mfa"], maxAuthAgeSeconds: 600 }. In TypeScript, type the request as AuthenticatedRequest (exported by the subpath) to read req.auth.

Python (Django, Flask, FastAPI)

Install the extra for your framework: pip install "keenauth-python[django]", "keenauth-python[flask]" or "keenauth-python[fastapi]". The framework is imported only by its own adapter module.

from keenauth.middleware import Protector
from keenauth.verify import Verifier, VerifierConfig

verifier = Verifier(VerifierConfig(issuer="https://acme.eu.keenauth.app", audience="https://api.acme.example"))
protector = Protector(verifier, public_base_url="https://api.acme.example")

Django:

from django.http import JsonResponse
from keenauth.middleware.django import protect

@protect(protector, scopes=["orders:read"])
def orders(request):
    return JsonResponse({"sub": request.auth.subject})

Flask:

from flask import g
from keenauth.middleware.flask import protect

@app.get("/orders")
@protect(protector, scopes=["orders:read"])
def orders():
    return {"sub": g.auth.subject}

FastAPI:

from fastapi import Depends, FastAPI
from keenauth.middleware.fastapi import RequireAuth, install
from keenauth.verify import AccessClaims

app = FastAPI()
install(app)

@app.get("/orders")
def orders(claims: AccessClaims = Depends(RequireAuth(protector, scopes=["orders:read"]))):
    return {"sub": claims.subject}

install(app) registers the handler that writes the problem body and one WWW-Authenticate header per challenge. Step-up uses acceptable_acr=["urn:idp:acr:mfa"], max_auth_age=timedelta(minutes=10).

The public base URL (required)

A DPoP proof names the URL the client called (htu). The middleware compares it with the public base URL you configure (PublicBaseURL, publicBaseUrl, public_base_url) followed by the request path. Set it to the address clients use, for example https://api.acme.example or https://acme.example/api. It is required: creating the middleware without it fails at startup with a configuration error, even if your clients send only Bearer tokens.

The middleware never builds this URL from the request's Host header or from X-Forwarded-* headers, and in Python it does not use the framework's own request URL, even if the framework or server is configured to trust forwarded headers. The client chooses these values: if your API is served under two host names, a proof made for one of them could otherwise be replayed to the other. Behind a load balancer or reverse proxy, the public base URL is also what tells the middleware the scheme, host and path prefix that clients see. The public base URL must use https, except for localhost, 127.0.0.1 and [::1] (RFC 6750 §5.3); the scheme and host are compared case-insensitively and a default port (:443, :80) is ignored, both in the base URL and in the proof's htu.

Responses

Situation Status WWW-Authenticate code
No token, or an unsupported scheme 401 Bearer and DPoP algs="…", without an error missing_token
Token in the query, repeated or malformed Authorization 400 Bearer error="invalid_request" invalid_request
Invalid, expired or revoked token 401 error="invalid_token" invalid_token
Missing, repeated or invalid DPoP proof 401 DPoP error="invalid_dpop_proof" invalid_dpop_proof
A required scope is missing 403 error="insufficient_scope", scope="…" insufficient_scope
Step-up needed 401 error="insufficient_user_authentication", acr_values="…", max_age="…" insufficient_user_authentication
Signing keys or revocation state unavailable 503 none jwks_unavailable, revocation_unavailable

The challenge uses the scheme the client used. When you set the resource metadata URL, every 401 challenge carries resource_metadata (RFC 9728). The body has type, title, status, code and a fixed detail; it never says why a token was invalid. For your logs, pass an error callback (OnError, onError, on_error): it receives the verifier's error, which contains no token or claim value. Do not log the Authorization or DPoP headers yourself.

Revocation cache (token.revoked)

A JWT access token stays valid until it expires. To reject revoked tokens sooner, handle the token.revoked webhook and record its selector in a revocation cache that the middleware consults. The SDKs include an in-memory cache: protect.NewMemoryRevocationCache (Go), createMemoryRevocationCache (Node.js) and MemoryRevocationCache (Python).

A selector names a token ID (jti), a session (sid), a user (sub), an organization (org_id), an application (client_id), or the pairs organization + user and application + user. A jti selector revokes that token. The other selectors revoke every token issued before the event's not_before; tokens issued later are accepted. Markers are kept for 24 hours and 1 minute by default, the longest possible access token lifetime plus clock skew.

cache := protect.NewMemoryRevocationCache(0, nil)
m, _ := protect.New(v, protect.Options{PublicBaseURL: "https://api.acme.example", Revocation: cache})

// In your verified token.revoked webhook handler:
err := cache.Revoke(protect.Selector{SessionID: "ses_…"}, notBefore)

The in-memory cache belongs to one process. If your API runs on several instances, implement the revocation checker interface over a shared store. If the cache cannot answer, the middleware refuses the request with 503.

Testing

Use the offline test kit to mint tokens for your middleware tests. The kit has no DPoP proof helper; sign a proof in your test with the language's crypto library.