
# 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

```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.

```ts
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.

```python
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

```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

```ts
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

```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 `Bearer` scheme is rejected with `invalid_token`, even if a valid proof is attached (RFC 9449 §7.2).
- A token sent with the `DPoP` scheme 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

```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

```ts
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

```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 `iss` must equal the configured issuer. Keys are fetched only from the `jwks_uri` in that issuer's discovery document, and the `kid` is 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 `alg` must equal the token's `alg`; `none` and HMAC algorithms are always rejected.
- **Access tokens:** `typ` must be `at+jwt` (RFC 9068), `aud` must contain your configured audience, and `sub` and `client_id` must be present.
- **ID tokens:** `aud` must contain your client ID, `azp` must equal it when present, and `nonce` and `max_age` are checked when you pass them.
- **Time:** `exp` is required; `nbf` and `iat` must 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 unknown `kid` triggers 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](/docs/sdk/testing). It mints test tokens that only a verifier in explicit testing mode accepts; a production verifier always rejects them.
