> ## Documentation Index
> Fetch the complete documentation index at: https://docs.whirl.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Private key JWT

> Authenticate a server-side app by signing a JWT with your private key, instead of sending a secret.

`private_key_jwt` is optional. Public clients use `none` with PKCE, and dynamically registered apps can use a client secret instead. Use a private key if your app runs on a server and can store and rotate keys.

## How it works

Your app keeps a private key, and Whirl reads the matching **public** key from your JWKS. For each token or revocation request, your app signs a short-lived JWT. Whirl checks the signature and claims, and rejects any JWT ID it has seen before.

A JWKS only contains public keys, so it isn't a secret. PKCE still protects the code exchange. The signed JWT identifies your app, not the user.

## Publishing your public keys

With a metadata document, add a `jwks_uri`. With Dynamic Client Registration, send either a `jwks_uri` or an inline `jwks`, not both. Give each key a unique `kid`. Whirl rejects private and symmetric keys.

Supported algorithms are **RS256**, **PS256** and **ES256**. RSA keys must be at least 2048 bits, and EC keys must use P-256. The authorization server's discovery document lists the supported algorithms.

## JWT claims

| Field | Value |
| - | - |
| Header `alg` | A supported algorithm that matches the key. |
| Header `kid` | The ID of one of your registered public keys. |
| `iss` and `sub` | Your client ID. |
| `aud` | The API issuer, or its `/oauth/token` URL. Not `/mcp` or `/api/v1`. |
| `iat` | The current Unix time, in seconds. |
| `exp` | No more than five minutes after `iat`. |
| `jti` | A new random ID, up to 256 characters. |

Whirl allows 30 seconds of clock skew. It only uses keys from your registration, never key URLs inside the JWT.

Sign the JWT with a well-maintained JOSE library, with a new `jti` every time. Add these form fields to your authorization code, refresh or revocation request:

```text theme={null}
client_id=<your client ID>
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<signed JWT>
```

Don't send a client secret as well. A client registered with only a key can't fall back to a secret or to public authentication. Each `jti` can only be used once, across both token and revocation requests, so sign a new JWT whenever you retry.

## Rotating keys

A hosted JWKS can be up to **64 KiB with 10 keys**, and must load within five seconds, including DNS. Whirl caches it for up to an hour, or less if your cache headers say so. An unknown `kid` makes Whirl fetch it again, at most once a minute per URL.

1. Publish the old and new public keys together, with different `kid` values.
2. Wait long enough for Whirl's cache to pick up the new key.
3. Start signing with the new key.
4. Remove the old public key. Whirl stops accepting it once its cache refreshes.

If a fetch fails, Whirl won't fall back to expired cached keys. Temporary fetch failures return `503 temporarily_unavailable` with `Retry-After: 60`. To change keys registered inline with Dynamic Client Registration, register a new client. Use a hosted JWKS if you want to rotate keys.

Removing a key doesn't revoke access tokens that have already been issued. Revoke the tokens, or ask the user to disconnect the app, to cut off access straight away.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.