Client assertion
We've established prior trust and I am indeed the client that I claim to be.
Create a key pair and register the client
The client signs with its private key. The authorization server registers the public key against a client ID, then uses that key to verify assertions.
Registration is simulated and lasts 30 minutes. If it expires or the demo resets, register again. Real client registration is outside RFC 7523.
In practice, you might create a client in your identity provider’s console and upload a public key or set a JWKS URL. The provider assigns a client_id and links it to those keys. Some providers support a dynamic registration API, such as RFC 7591.
Not registeredKey pair creation code
const pair = await crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
false, // Private key cannot be exported.
["sign", "verify"],
);
const publicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
// Register publicJwk with the authorization server before authentication.
Construct the JWT
Example values are shown below. Register the public key in step 1 to fill in the client ID, key ID, audience, and fresh times.
| Field | Purpose |
|---|---|
alg | Signature algorithm. This demo accepts RS256. |
typ | Token type. JWT is used here; RFC 7523 does not require it. |
kid | Key ID. Identifies a registered public key; does not establish trust. |
| Field | Purpose |
|---|---|
iss | Required. JWT issuer. Demo policy: the client ID. |
sub | Required. The client ID for client authentication. |
aud | Required. Authorization server identity. This demo uses the exact token endpoint URL. |
exp | Required. Expiration time. |
iat | Issue time. Optional in the RFC; required by this demo. |
jti | Unique assertion ID. Optional in the RFC; required here to reject reuse. |
nbf | Optional. Earliest acceptance time, if supplied. |
Client authentication and JWT authorization grants
Here, client_assertion authenticates the client. The separate grant_type=client_credentials selects the grant. RFC 7523 also defines JWT authorization grants, which use assertion and a JWT bearer grant_type. Those parameters serve a different purpose.
Sign the assertion
The signature covers the encoded header and payload. Changing either part after signing makes the signature invalid.
Signing code
function base64url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(/=+$/, "");
}
function encodeJson(value: unknown): string {
return base64url(new TextEncoder().encode(JSON.stringify(value)));
}
const header = { alg: "RS256", typ: "JWT", kid: registeredKeyId };
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: clientId,
sub: clientId,
aud: tokenEndpoint,
iat: now,
exp: now + 120,
jti: crypto.randomUUID(),
};
const input = `${encodeJson(header)}.${encodeJson(payload)}`;
const signature = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
pair.privateKey,
new TextEncoder().encode(input),
);
const assertion = `${input}.${base64url(new Uint8Array(signature))}`;
Verify on the authorization server
Send a token request with the signed client assertion. The server authenticates the client, checks the grant, and returns a simulated access token.
| Field | Purpose |
|---|---|
grant_type | client_credentials |
client_id | Register a client first. |
scope | demo:read |
client_assertion_type | urn:ietf:params:oauth:client-assertion-type:jwt-bearer |
client_assertion | The signed JWT from step 3. |
POST /api/client-assertion/token
Content-Type: application/x-www-form-urlencoded
Send the same assertion twice to test replay rejection. Reset the claims and sign again for a new request. Replay protection is optional in RFC 7523; this demo enables it.
Concise server verification code
import { jwtVerify } from "jose";
// Look up an existing client; never trust a key supplied in the JWT.
const client = await loadRegisteredClient(form.get("client_id"));
const { payload } = await jwtVerify(form.get("client_assertion"), client.key, {
algorithms: ["RS256"],
issuer: client.id,
subject: client.id,
audience: tokenEndpoint,
requiredClaims: ["iss", "sub", "aud", "exp", "iat", "jti"],
maxTokenAge: "2m",
});
if (
typeof payload.exp !== "number" ||
typeof payload.iat !== "number" ||
payload.exp - payload.iat > 120 ||
payload.exp <= payload.iat
) {
throw new Error("Invalid assertion lifetime");
}
// Demo policy: validate jti and atomically reject reuse until exp.
if (
typeof payload.jti !== "string" ||
!payload.jti ||
payload.jti.length > 128
) {
throw new Error("Invalid assertion ID");
}
await consumeJti(client.id, payload.jti, payload.exp);
jwtVerify is from jose. loadRegisteredClient and consumeJti are application functions. The latter must atomically reject a duplicate client ID and jti pair until exp. This excerpt assumes validated form parameters and grant policy.
This demo creates temporary clients and simulated access tokens. The tokens grant no access to real resources. See RFC 7523, client authentication.