Confidential Clients (private_key_jwt)
Authenticate your backend at the Hawcx token endpoint with an RFC 7523 private_key_jwt assertion — with the Hawcx SDK or any stock JWT library
By default a Hawcx project is a public client — the PKCE code_verifier
alone proves the caller at the token endpoint. This is secure on its own and is
the right choice for most apps. In the Admin Console this default is called
Default mode.
Optionally, a backend service can upgrade the project to a confidential
client, where every code exchange must also carry a signed private_key_jwt
client assertion (RFC 7523 / OIDC Core 1.0 §9) — proving your backend's identity
with a key only it holds. In the Admin Console this is Enhanced mode. Hawcx
implements the standard verbatim, so you can use the Hawcx SDK or any OIDC/JWT
library.
Optional — not required
private_key_jwt is opt-in. Most projects stay on Default (PKCE) — PKCE
already binds the authorization code to the client that started the flow.
Enable Enhanced mode only for backend services that want the extra assurance
of authenticating with a private key. Browser and mobile apps should keep the
Default.
Page Summary
- Optional, not required. Most projects stay on Default (PKCE);
Enhanced mode (
private_key_jwt) is opt-in for backend services that want to authenticate with a private key. - The method is a per-project setting (Admin Console → Settings → OAuth →
Client authentication): Default (
none, PKCE only) or Enhanced mode (private_key_jwt). - In Enhanced mode, the token exchange must include a signed
client_assertionJWT in addition to PKCE. - The assertion is EdDSA over Ed25519 only — the single algorithm the Hawcx OP accepts for client authentication.
- Use the Hawcx SDK's
withClientAssertion(...), or hand-build the assertion with any JWT library (examples in Node, Python, and Java below). - Switching between PKCE and
private_key_jwtrequires a change on both sides: the project setting in the Admin Console and your backend.
When to use it
PKCE already binds the authorization code to the client that started the flow,
so public-client mode is secure for most backends. Reach for private_key_jwt
when you want the token endpoint to additionally prove your backend's
identity with a key only it holds — for higher-assurance deployments, or to
satisfy a policy that requires confidential-client authentication.
Both sides must agree
The project setting and your backend must match. If the project is set to
private_key_jwt but your backend sends no assertion, the exchange fails with
invalid_client. If the project is set to none but you send an assertion,
the assertion is ignored. Flip the Admin Console setting and your backend
together.
Enabling Enhanced mode in the Admin Console
The client-authentication method is a per-project setting. In the Hawcx Admin Console, open your project → Settings → the OAuth tab → the Client authentication card. It offers two modes:
- Default — PKCE only. No setup. Right for browser and mobile apps. Every project starts here.
- Enhanced mode — your backend signs each token request with a key it holds
(
private_key_jwt).
To turn on Enhanced mode:
- In the Client authentication card, select Enhanced mode.
- Register a signing key — two ways:
- Paste (recommended) — paste the public JWK of an Ed25519 key you generated yourself. Only the public half is sent to Hawcx.
- Generate — the console generates an Ed25519 key pair in your browser. Download the private key first (the register button stays disabled until you do — the private half never leaves your browser and Hawcx never sees it), then register to send the public half.
- Configure your backend to sign assertions with the private key (the downloaded JWK). Store it as a secret — never commit it, never expose it to the browser.
Once registered, the card marks Enhanced mode "Currently configured" and
lists the active key by its kid.
One key per project (rotation)
A project has exactly one signing key. Registering a new key replaces the existing one in a single atomic write — that's how you rotate. After rotating, update your backend's secret; assertions signed with the old key stop working immediately.
Reverting to Default (PKCE)
In the same card, select Default, click "Switch to Default and remove key", and confirm. The signing key is removed and the project returns to PKCE-only. Remove the private key from your backend too; in Default mode any assertion you still send is simply ignored, so reverting won't break sign-in.
Option A — with the Hawcx SDK
The SDK builds, signs, and attaches the assertion on every exchange. Opt in once
with withClientAssertion(...), passing the downloaded private JWK. PKCE is still
sent; the assertion is added on top. See each SDK's integrate guide for the full
setup: Node.js, Python,
Java.
Node.js — @hawcx/oauth-client
import { HawcxOAuth, ClientAssertionSigner } from '@hawcx/oauth-client';
const oauth = (await HawcxOAuth.fromIssuer({
issuer: process.env.HAWCX_BASE_URL!,
configId: process.env.HAWCX_CONFIG_ID!,
clientId: process.env.HAWCX_CLIENT_ID!,
})).withClientAssertion(
ClientAssertionSigner.ed25519FromJwk(process.env.HAWCX_OAUTH_PRIVATE_KEY_JWK!),
);
// Same call as a public client — the assertion is attached automatically.
const { idToken, claims } = await oauth.exchangeCode(code, codeVerifier);Python — hawcx-backend-sdk
from hawcx_backend_sdk.oauth import HawcxOAuth, ClientAssertionSigner
oauth = HawcxOAuth.from_issuer(
os.environ["HAWCX_BASE_URL"],
os.environ["HAWCX_CONFIG_ID"],
os.environ["HAWCX_CLIENT_ID"],
).with_client_assertion(
ClientAssertionSigner.ed25519_from_jwk(os.environ["HAWCX_OAUTH_PRIVATE_KEY_JWK"])
)
result = oauth.exchange_code(code, code_verifier) # assertion attached automatically
claims = result.claimsJava — com.hawcx:hawcx-java-sdk
import com.hawcx.oauth.HawcxOauthClient;
import com.hawcx.oauth.ClientAssertionSigner;
HawcxOauthClient oauth = HawcxOauthClient
.fromIssuer(System.getenv("HAWCX_BASE_URL"),
System.getenv("HAWCX_CONFIG_ID"),
System.getenv("HAWCX_CLIENT_ID"))
.withClientAssertion(
ClientAssertionSigner.ed25519FromJwk(System.getenv("HAWCX_OAUTH_PRIVATE_KEY_JWK")));
String idToken = oauth.exchangeCodeForIdToken(code, codeVerifier); // assertion attached
Map<String, Object> claims = oauth.verifyToken(idToken);EdDSA / Ed25519 only
ed25519FromJwk (and the OP) accept only EdDSA over Ed25519. The downloaded JWK
is already {"kty":"OKP","crv":"Ed25519",...}; other key types are rejected.
Option B — with a stock JWT library (no Hawcx SDK)
Because private_key_jwt is a standard, you don't need the Hawcx SDK. Build the
assertion with any JWT library, then POST it to the token endpoint alongside the
PKCE fields.
The assertion (RFC 7523)
A compact JWS signed with your Ed25519 private key.
Header
| Field | Value |
|---|---|
alg | EdDSA |
typ | JWT |
kid | the kid from your private JWK |
Claims
| Claim | Value |
|---|---|
iss | your Client ID |
sub | your Client ID (same as iss) |
aud | the token endpoint URL (token_endpoint from the discovery document) |
iat | now (epoch seconds) |
exp | iat + lifetime; keep it short. Max accepted is 120s; 60s is a good default |
jti | a unique value per assertion (single-use; e.g. a UUID) |
The token request
POST to {issuer}/oauth2/token (form-encoded), adding three parameters to the
usual PKCE exchange:
| Parameter | Value |
|---|---|
code | the authorization code from the Hawcx SDK |
code_verifier | the PKCE verifier |
client_id | your Client ID |
client_assertion_type | urn:ietf:params:oauth:client-assertion-type:jwt-bearer |
client_assertion | the signed JWT from above |
The X-Config-Id header is still required for gateway routing (see the
Token & JWKS Reference).
Example: Node.js (jose)
import { SignJWT, importJWK } from 'jose';
import { randomUUID } from 'node:crypto';
const privateJwk = JSON.parse(process.env.HAWCX_OAUTH_PRIVATE_KEY_JWK!);
const signingKey = await importJWK(privateJwk, 'EdDSA');
const now = Math.floor(Date.now() / 1000);
const clientAssertion = await new SignJWT({})
.setProtectedHeader({ alg: 'EdDSA', typ: 'JWT', kid: privateJwk.kid })
.setIssuer(CLIENT_ID) // iss = client_id
.setSubject(CLIENT_ID) // sub = client_id
.setAudience(TOKEN_ENDPOINT) // aud = discovered token_endpoint
.setIssuedAt(now)
.setExpirationTime(now + 60) // <= 120s
.setJti(randomUUID())
.sign(signingKey);
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Config-Id': CONFIG_ID,
},
body: new URLSearchParams({
code,
code_verifier: codeVerifier,
client_id: CLIENT_ID,
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: clientAssertion,
}),
});
const { id_token } = await res.json();
// Then verify id_token — see "ID Token Verification".Example: Python (PyJWT)
import json, os, time, uuid
import jwt # PyJWT
import requests
private_jwk = json.loads(os.environ["HAWCX_OAUTH_PRIVATE_KEY_JWK"])
# PyJWT loads an Ed25519 (OKP) private key straight from the JWK:
signing_key = jwt.algorithms.OKPAlgorithm.from_jwk(json.dumps(private_jwk))
now = int(time.time())
client_assertion = jwt.encode(
{
"iss": CLIENT_ID, # iss = client_id
"sub": CLIENT_ID, # sub = client_id
"aud": TOKEN_ENDPOINT, # aud = discovered token_endpoint
"iat": now,
"exp": now + 60, # <= 120s
"jti": str(uuid.uuid4()),
},
signing_key,
algorithm="EdDSA",
headers={"typ": "JWT", "kid": private_jwk["kid"]},
)
resp = requests.post(
TOKEN_ENDPOINT,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Config-Id": CONFIG_ID,
},
data={
"code": code,
"code_verifier": code_verifier,
"client_id": CLIENT_ID,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": client_assertion,
},
)
id_token = resp.json()["id_token"]
# Then verify id_token — see "ID Token Verification".Example: Java (nimbus-jose-jwt)
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.Ed25519Signer;
import com.nimbusds.jose.jwk.OctetKeyPair;
import com.nimbusds.jwt.*;
import java.time.Instant;
import java.util.Date;
import java.util.UUID;
OctetKeyPair jwk = OctetKeyPair.parse(privateJwkJson); // Ed25519 OKP private key
Instant now = Instant.now();
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer(CLIENT_ID) // iss = client_id
.subject(CLIENT_ID) // sub = client_id
.audience(TOKEN_ENDPOINT) // aud = discovered token_endpoint
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plusSeconds(60))) // <= 120s
.jwtID(UUID.randomUUID().toString())
.build();
SignedJWT assertion = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.EdDSA)
.type(JOSEObjectType.JWT)
.keyID(jwk.getKeyID())
.build(),
claims);
assertion.sign(new Ed25519Signer(jwk));
String clientAssertion = assertion.serialize();
// POST form to {issuer}/oauth2/token with header X-Config-Id and fields:
// code, code_verifier, client_id,
// client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer,
// client_assertion = clientAssertion
// Then verify the returned id_token — see "ID Token Verification".Finding TOKEN_ENDPOINT
TOKEN_ENDPOINT is the token_endpoint field of the discovery document at
{issuer}/.well-known/openid-configuration (it is {issuer}/oauth2/token). Use
the value from discovery verbatim as the assertion aud — the OP pins it.
Troubleshooting
| Symptom | Cause |
|---|---|
invalid_client at the token endpoint | The project is set to private_key_jwt but the assertion was missing, signed with the wrong key (post-rotation), expired (exp > 120s or already past), or had the wrong aud/iss/sub. |
| Assertion silently ignored | The project is still set to Public (PKCE) — flip it to private_key_jwt in the Admin Console. |
unsupported algorithm when building the assertion | The key isn't Ed25519, or the library was told to use something other than EdDSA. Hawcx accepts EdDSA/Ed25519 only. |
Next Steps
- ID Token Verification — verify the
id_tokenyou get back. - Token & JWKS Reference — the full token-endpoint contract.