Verifying tokens in your API
The SDK token verifier checks access tokens and ID tokens issued by your KeenAuth environment. Use it in the API you protect. All three SDKs (Go, TypeScript and Python) have it, and they follow the same rules and the same test vectors.
Go
import "github.com/keenauth/keenauth-go/verify"
v, err := verify.New(verify.Config{
Issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
Audience: "https://api.acme.example", // the identifier of your API
})
if err != nil {
return err
}
// In your HTTP handler:
claims, err := v.VerifyAccessToken(r.Context(), token, &verify.DPoPInput{
Proof: r.Header.Get("DPoP"),
Method: r.Method,
URL: "https://api.acme.example" + r.URL.Path,
})
if err != nil {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// claims.Subject, claims.ClientID, claims.Scope
For ID tokens, set ClientID in the configuration and call v.VerifyIDToken(ctx, idToken, verify.IDTokenOptions{Nonce: nonce}).
TypeScript
The TypeScript verifier uses only the platform's WebCrypto and fetch (Node.js 22 or later) and has no dependencies.
import { createVerifier, VerifyError } from "@keenauth/node";
const verifier = createVerifier({
issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
audience: "https://api.acme.example", // the identifier of your API
});
// In your HTTP handler:
try {
const claims = await verifier.verifyAccessToken(token, {
proof: req.get("DPoP") ?? "",
method: req.method,
url: "https://api.acme.example" + req.path,
});
// claims.subject, claims.clientId, claims.scope
} catch (e) {
if (!(e instanceof VerifyError)) throw e;
res.set("WWW-Authenticate", 'Bearer error="invalid_token"').status(401).send("unauthorized");
}
For ID tokens, set clientId in the configuration and call verifier.verifyIdToken(idToken, { nonce }).
Python
The Python verifier needs the cryptography package, which comes with the verify extra: pip install "keenauth-python[verify]". The calls are synchronous and thread-safe.
from keenauth.verify import DPoPInput, Verifier, VerifierConfig, VerifyError
verifier = Verifier(VerifierConfig(
issuer="https://acme.eu.keenauth.app", # your environment's issuer, exactly
audience="https://api.acme.example", # the identifier of your API
))
# In your HTTP handler:
try:
claims = verifier.verify_access_token(
token,
DPoPInput(
proof=request.headers.get("DPoP", ""),
method=request.method,
url="https://api.acme.example" + request.path,
),
)
# claims.subject, claims.client_id, claims.scope
except VerifyError:
return Response("unauthorized", status=401, headers={"WWW-Authenticate": 'Bearer error="invalid_token"'})
For ID tokens, set client_id in the configuration and call verifier.verify_id_token(id_token, nonce=nonce).
Step-up (RFC 9470)
To protect a sensitive operation, require a stronger or fresher sign-in per call. Pass the acceptable authentication context class references (acr) and the maximum age of the sign-in (auth_time). dpop below is the DPoP input from the examples above (nil/undefined/None if you do not use DPoP).
Go
age := 15 * time.Minute
claims, err := v.VerifyAccessToken(r.Context(), token, dpop, verify.AccessTokenOptions{
AcceptableACR: []string{"urn:idp:acr:mfa", "urn:idp:acr:phr"},
MaxAuthAge: &age,
})
if challenge, ok := verify.StepUpChallenge(err); ok {
w.Header().Set("WWW-Authenticate", challenge)
http.Error(w, "step-up required", http.StatusUnauthorized)
return
}
TypeScript
import { createVerifier, stepUpChallenge } from "@keenauth/node";
try {
const claims = await verifier.verifyAccessToken(token, dpop, {
acceptableAcr: ["urn:idp:acr:mfa", "urn:idp:acr:phr"],
maxAuthAgeSeconds: 900,
});
} catch (e) {
const challenge = stepUpChallenge(e);
if (challenge === undefined) throw e;
res.set("WWW-Authenticate", challenge).status(401).send("step-up required");
}
Python
from datetime import timedelta
from keenauth.verify import VerifyError, step_up_challenge
try:
claims = verifier.verify_access_token(
token,
dpop,
acceptable_acr=["urn:idp:acr:mfa", "urn:idp:acr:phr"],
max_auth_age=timedelta(minutes=15),
)
except VerifyError as err:
challenge = step_up_challenge(err)
if challenge is None:
raise
return Response("step-up required", status=401, headers={"WWW-Authenticate": challenge})
The checks run after every other check, so an invalid token is always invalid_token. A valid token that does not meet the requirement gets the code insufficient_user_authentication, and the error carries the required acr_values and max_age (only the ones you set). StepUpChallenge (Go), stepUpChallenge (TypeScript) and step_up_challenge (Python) turn it into the WWW-Authenticate header value of RFC 9470 §3, for example Bearer error="insufficient_user_authentication", error_description="authentication too old", max_age="900". A client that understands the challenge starts a new authorization request with those acr_values and max_age. acr must equal one of the values exactly (case-sensitive); auth_time is required when you set a maximum age, and the clock skew allowance applies as for ID tokens. The values urn:idp:acr:1fa, urn:idp:acr:mfa and urn:idp:acr:phr are the ones your environment publishes in acr_values_supported.
DPoP and the Authorization scheme (RFC 9449)
A DPoP-bound token (cnf.jkt) is useless without the private key of its client, so a stolen copy cannot be replayed. Two rules keep it that way, and the verifier enforces both when you pass the scheme of the request's Authorization header (Bearer or DPoP):
- A DPoP-bound token sent with the
Bearerscheme is rejected withinvalid_token, even if a valid proof is attached (RFC 9449 §7.2). - A token sent with the
DPoPscheme must be DPoP-bound and come with a valid proof (RFC 9449 §7.1).
To accept only sender-constrained tokens on an API (recommended for sensitive APIs), also set "require DPoP": a token that is not DPoP-bound gets the code dpop_required. The DPoP challenge helper turns any 401 error into the WWW-Authenticate value of RFC 9449 §7.1, for example DPoP error="invalid_token", error_description="a DPoP-bound access token is required", algs="ES256 EdDSA PS256 RS256".
Go
claims, err := v.VerifyAccessToken(r.Context(), token, dpop, verify.AccessTokenOptions{
Scheme: scheme, // "Bearer" or "DPoP", from the Authorization header
RequireDPoP: true,
})
if challenge, ok := verify.DPoPChallenge(err); ok {
w.Header().Set("WWW-Authenticate", challenge)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
TypeScript
import { dpopChallenge } from "@keenauth/node";
try {
const claims = await verifier.verifyAccessToken(token, dpop, { scheme, requireDPoP: true });
} catch (e) {
const challenge = dpopChallenge(e);
if (challenge === undefined) throw e;
res.set("WWW-Authenticate", challenge).status(401).send("unauthorized");
}
Python
from keenauth.verify import VerifyError, dpop_challenge
try:
claims = verifier.verify_access_token(token, dpop, scheme=scheme, require_dpop=True)
except VerifyError as err:
challenge = dpop_challenge(err)
if challenge is None:
raise
return Response("unauthorized", status=401, headers={"WWW-Authenticate": challenge})
The resource server middleware (Go protect, TypeScript @keenauth/node/middleware, Python keenauth.middleware) reads the scheme for you. Its "require DPoP" setting (per API or per route) answers a Bearer request with 401 and the DPoP challenge only.
What the verifier checks
- Issuer and keys: the token's
issmust equal the configured issuer. Keys are fetched only from thejwks_uriin that issuer's discovery document, and thekidis looked up only in that key set. A token signed by another environment's key is rejected. - Algorithm: only RS256, PS256, ES256 and EdDSA. The key's
algmust equal the token'salg;noneand HMAC algorithms are always rejected. - Access tokens:
typmust beat+jwt(RFC 9068),audmust contain your configured audience, andsubandclient_idmust be present. - ID tokens:
audmust contain your client ID,azpmust equal it when present, andnonceandmax_ageare checked when you pass them. - Time:
expis required;nbfandiatmust not be in the future. Clock skew of at most 60 seconds is allowed. - DPoP: when the token is bound to a key (
cnf.jkt), a valid DPoP proof (RFC 9449) is required. Reused proofs are rejected. With the scheme option a bound token is never accepted as a bearer token; with "require DPoP" only bound tokens are accepted. - Key cache: keys are cached for the server's
max-age(300 seconds by default, at most 600). An unknownkidtriggers at most one refetch per minute.
Errors
Every error has a Code. The message never contains the token or its claims.
| Code | Meaning | Respond with |
|---|---|---|
invalid_token |
The token is invalid, expired, or not for this API. | 401 |
jwks_unavailable |
The issuer's keys could not be fetched and none are cached. | 503 |
dpop_invalid |
The DPoP proof is missing or invalid. | 401 with WWW-Authenticate from the DPoP challenge helper |
dpop_required |
You require DPoP and the token is not DPoP-bound. | 401 with WWW-Authenticate from the DPoP challenge helper |
insufficient_user_authentication |
The token is valid but its acr or auth_time does not meet your requirement (RFC 9470). |
401 with WWW-Authenticate from the step-up helper |
config_invalid |
The verifier configuration is wrong. | Fix the configuration. |
If you use another JWT library
Apply the same checklist: bind the issuer to its discovery document, take keys only from that jwks_uri, require the key's alg to equal the token's alg, and reject none and HMAC algorithms.
Testing offline
For unit tests and CI, use the offline test kit. It mints test tokens that only a verifier in explicit testing mode accepts; a production verifier always rejects them.