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_assertion JWT 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_jwt requires 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:

  1. In the Client authentication card, select Enhanced mode.
  2. 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.
  3. 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.claims

Java — 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

FieldValue
algEdDSA
typJWT
kidthe kid from your private JWK

Claims

ClaimValue
issyour Client ID
subyour Client ID (same as iss)
audthe token endpoint URL (token_endpoint from the discovery document)
iatnow (epoch seconds)
expiat + lifetime; keep it short. Max accepted is 120s; 60s is a good default
jtia 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:

ParameterValue
codethe authorization code from the Hawcx SDK
code_verifierthe PKCE verifier
client_idyour Client ID
client_assertion_typeurn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertionthe 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

SymptomCause
invalid_client at the token endpointThe 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 ignoredThe project is still set to Public (PKCE) — flip it to private_key_jwt in the Admin Console.
unsupported algorithm when building the assertionThe key isn't Ed25519, or the library was told to use something other than EdDSA. Hawcx accepts EdDSA/Ed25519 only.

Next Steps