Skip to content
Registry StackDocsDevelopment (unreleased)

Request an access token from your own code

View as Markdown

Build the signed request Registry Mint expects when the caller is your own application rather than the mint token command.

Use this page when you are integrating a registered client into an application and need an access token to present to a resource server such as Evidence Gateway. If you only need a token at a terminal, mint token already does all of this; see Obtain a token.

You need four things, all of which come from whoever registered your client:

  • The clientId your client is registered under.
  • Your client’s private JWK. Registry Mint holds only the public half; if you do not have the private half, nobody can issue you a token.
  • The exact clientAssertion.audience value the deployment is configured with. This is not the same as the audience of the token you get back, and confusing the two is the most common first-attempt failure.
  • The token endpoint URL. The path is always /token and is not configurable.

Registry Mint serves plain HTTP and expects TLS termination it does not manage, so the URL you call is the terminator’s, not the process’s own listener.

One JWT, signed with your own private key, sent once. This is private_key_jwt client authentication (RFC 7523) inside the client_credentials grant: there is no shared secret anywhere in the exchange.

ClaimValueWhy it is checked
issyour clientIdNames which registration’s keys to verify against.
subyour clientId, identical to issWithout it a legitimate client key could sign an assertion naming a different subject.
audthe configured clientAssertion.audienceStops a request built for one endpoint being replayed at another.
jtia fresh unique value per requestAccepted exactly once. Reusing one is refused.
iatnowFreshness, tolerating a small clock skew.
expwithin clientAssertion.maximumLifetimeSeconds of iat (default 300)A long-lived assertion is a long-lived bearer credential, so the bound is enforced whatever you choose.

The header carries alg (one of the algorithms the deployment lists under clientAssertion.algorithms), typ: JWT, and kid naming which of your registered keys signed it.

The examples below build the JWT from primitives rather than pulling in a JWT library, so that every field above is visible in the code. A JWT library is a perfectly good substitute as long as it lets you set kid and does not cache or reuse jti.

Needs cryptography and requests.

import base64
import json
import time
import uuid
import requests
from cryptography.hazmat.primitives.asymmetric import ed25519
def b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def unb64url(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def sign_client_assertion(
private_jwk: dict,
client_id: str,
audience: str,
lifetime_seconds: int = 120,
) -> str:
"""Build one single-use assertion proving this client holds its own key."""
now = int(time.time())
header = {"alg": "EdDSA", "typ": "JWT", "kid": private_jwk["kid"]}
claims = {
"iss": client_id,
"sub": client_id,
"aud": audience,
"iat": now,
"exp": now + lifetime_seconds,
"jti": str(uuid.uuid4()),
}
signing_input = ".".join(
b64url(json.dumps(part, separators=(",", ":")).encode())
for part in (header, claims)
)
key = ed25519.Ed25519PrivateKey.from_private_bytes(unb64url(private_jwk["d"]))
return f"{signing_input}.{b64url(key.sign(signing_input.encode()))}"
def request_access_token(token_url: str, assertion: str) -> str:
response = requests.post(
token_url,
data={
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": assertion,
},
timeout=10,
)
response.raise_for_status()
return response.json()["access_token"]

Call it with the client’s own private JWK:

with open("client-signing-private-jwk", encoding="utf-8") as handle:
private_jwk = json.load(handle)
assertion = sign_client_assertion(
private_jwk,
client_id="health-desk",
audience="https://mint.example.org/token",
)
token = request_access_token("https://mint.example.org/token", assertion)

Needs no dependencies. node:crypto reads an Ed25519 JWK directly, and fetch is built in.

import { createPrivateKey, randomUUID, sign } from "node:crypto";
interface PrivateJwk {
kty: "OKP";
crv: "Ed25519";
kid: string;
d: string;
x: string;
}
const b64url = (raw: Buffer | string): string =>
Buffer.from(raw).toString("base64url");
/** Build one single-use assertion proving this client holds its own key. */
export function signClientAssertion(
privateJwk: PrivateJwk,
clientId: string,
audience: string,
lifetimeSeconds = 120,
): string {
const now = Math.floor(Date.now() / 1000);
const header = { alg: "EdDSA", typ: "JWT", kid: privateJwk.kid };
const claims = {
iss: clientId,
sub: clientId,
aud: audience,
iat: now,
exp: now + lifetimeSeconds,
jti: randomUUID(),
};
const signingInput = [header, claims]
.map((part) => b64url(JSON.stringify(part)))
.join(".");
const key = createPrivateKey({ key: privateJwk, format: "jwk" });
// Ed25519 signs the message itself, so the digest argument is null.
const signature = sign(null, Buffer.from(signingInput), key);
return `${signingInput}.${b64url(signature)}`;
}
export async function requestAccessToken(
tokenUrl: string,
assertion: string,
): Promise<string> {
const response = await fetch(tokenUrl, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_assertion_type:
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: assertion,
}),
});
if (!response.ok) {
const { error } = (await response.json()) as { error?: string };
throw new Error(`token request failed: ${response.status} ${error ?? ""}`);
}
const body = (await response.json()) as { access_token: string };
return body.access_token;
}

Present it as a bearer token to the resource server, not back to Registry Mint:

Terminal window
curl -sS https://evidence.example.org/v1/evidence \
-H "Authorization: Bearer ${TOKEN}" \
-H 'content-type: application/json' \
--data @request.json

Sign a new assertion per request. The jti is spent on first use and remembered past exp, so a cached assertion fails the second time it is sent. What you may cache is the access token, for the expires_in seconds the response reports.

The two audiences are different. The assertion’s aud is clientAssertion.audience, the value that identifies the token endpoint. The resulting token’s own aud comes from accessTokens.audiences and identifies the resource server. Signing the assertion with the resource server’s audience is a refusal, not a warning.

You cannot ask for more authority than you are registered with. Principal, requester tags, evidence audience, and grant pair are all written from the client registry. Nothing you put in the assertion changes them, so there is no scope parameter to send.

Every authentication failure looks identical. An unknown client id, a bad signature, a replayed jti, a wrong audience, and an expired assertion all return the same 401 {"error": "invalid_client"}. This is deliberate: the endpoint must not be usable to discover which client ids are registered. It also means the response cannot tell you which of those five things went wrong, so check them in order locally.

SymptomLikely cause
401 invalid_client on the first attemptThe aud is the resource server rather than clientAssertion.audience, or kid names a key the registration does not carry.
401 invalid_client only on repeat requestsThe assertion, rather than the token, is being cached and replayed. Generate a fresh jti each time.
401 invalid_client after a working periodThe assertion’s exp exceeds clientAssertion.maximumLifetimeSeconds, or the client clock has drifted beyond the tolerated skew.
400 invalid_requestA required form field is missing or duplicated, or client_assertion_type is not the exact jwt-bearer URN. A missing grant_type lands here too.
400 unsupported_grant_typegrant_type is present but is not exactly client_credentials.
The resource server rejects a token Registry Mint issuedThe two deployments name different claims for the same authority field. See How Evidence Gateway verifies these tokens.