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

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

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.