Skip to main content

Client Assertion (private_key_jwt)

The platform authenticates clients at the token endpoint with a JWT client assertion (private_key_jwt, RFC 7523), not a shared client secret. You sign a short-lived JWT with your private key; the platform verifies it with the public certificate you uploaded when you created your credentials.

This applies to every auth flow that calls the token endpoint - authorization code, device code, client credentials, and refresh. Wherever you see client_assertion in those flows, build it as described here.

The token endpoint is on the authorization server:

https://auth.jlr-vcdp.com/realms/public/protocol/openid-connect/token
No client secrets

There is no client_secret on this platform. Client authentication is always the signed JWT assertion below, bound to the public certificate on your client.

Never share your credentials

Your private key is a credential - keep it on your backend and never share it, commit it to source control, or expose it in a browser or mobile client. Anyone with it can authenticate as you.

How it fits together​

The assertion JWT​

{
"alg": "PS256",
"typ": "JWT"
}
kid is not used today

The kid (key ID) header is only meaningful when a client's keys are resolved from a JWKS endpoint. The platform does not currently support JWKS public-key URLs - it verifies your assertion against the X.509 certificate you uploaded to the portal, so kid is not required and is ignored. JWKS support may be added in future; until then you can omit kid.

Claims​

{
"iss": "YOUR_CLIENT_ID",
"sub": "YOUR_CLIENT_ID",
"aud": "https://auth.jlr-vcdp.com/realms/public/protocol/openid-connect/token",
"jti": "unique-random-id",
"iat": 1705312600,
"exp": 1705312900
}
ClaimValue
issYour client_id (the assertion issuer).
subYour client_id (the subject is the client itself).
audThe token endpoint URL.
jtiA unique id per assertion (prevents replay).
iatIssued-at (seconds since epoch).
expExpiry - keep it short (for example 5 minutes after iat).

Generate the key pair and certificate (Node.js)​

You sign the assertion with the private key whose self-signed X.509 certificate you uploaded to the portal. Generate both from one PS256 (RSASSA-PSS) key pair:

const x509 = require('@peculiar/x509');
const { webcrypto } = require('node:crypto');
const { importPKCS8 } = require('jose');

const crypto = webcrypto;
x509.cryptoProvider.set(crypto);

const alg = {
name: 'RSA-PSS',
hash: 'SHA-256',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
saltLength: 32,
};

async function generateKeyAndCertificate(clientName) {
const keys = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);

// Self-signed X.509 certificate (PEM) - upload this to the portal.
const cert = await x509.X509CertificateGenerator.createSelfSigned({
serialNumber: '01',
name: `CN=${clientName}`,
notBefore: new Date(),
notAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
signingAlgorithm: alg,
keys,
extensions: [new x509.BasicConstraintsExtension(false)],
});
const certificatePem = cert.toString('pem');

// Private key as PKCS8 PEM, re-imported for signing with jose.
const pkcs8 = await crypto.subtle.exportKey('pkcs8', keys.privateKey);
const pkcs8Pem =
'-----BEGIN PRIVATE KEY-----\n' +
Buffer.from(pkcs8).toString('base64').replace(/(.{64})/g, '$1\n') +
'\n-----END PRIVATE KEY-----\n';
const privateKey = await importPKCS8(pkcs8Pem, 'PS256');

return { privateKey, certificatePem };
}

Write certificatePem to a file and upload it on the public certificate step of credential creation. Keep the private key on your backend - it is never uploaded and there is no client secret.

Generate the assertion (Node.js)​

Use the same private key whose public certificate you uploaded during credential creation.

const { SignJWT } = require('jose');
const { v4: uuidv4 } = require('uuid');

// `privateKey` is a KeyLike / CryptoKey - e.g. from generateKeyAndCertificate()
// above, or importPKCS8() of a stored private key.
// `clientId` is the client_id issued when you created your credentials.
// No `kid` is set - the platform verifies against your uploaded certificate, not a
// JWKS endpoint (see note above).
async function createClientAssertion(privateKey, clientId) {
const now = Math.floor(Date.now() / 1000);

return await new SignJWT({})
.setProtectedHeader({ alg: 'PS256', typ: 'JWT' })
.setIssuer(clientId) // iss
.setSubject(clientId) // sub
.setAudience('https://auth.jlr-vcdp.com/realms/public/protocol/openid-connect/token') // aud
.setJti(uuidv4()) // jti
.setIssuedAt(now) // iat
.setExpirationTime(now + 300) // exp (5 minutes)
.sign(privateKey);
}

Send it to the token endpoint​

Add two parameters to any token request:

curl -X POST https://auth.jlr-vcdp.com/realms/public/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "client_assertion=YOUR_SIGNED_ASSERTION_JWT"
ParameterValue
client_assertion_typeurn:ietf:params:oauth:client-assertion-type:jwt-bearer (fixed).
client_assertionThe JWT you signed above.

Keys vs DPoP​

Keep two things distinct:

  • Client assertion authenticates the client at the token endpoint (this page). It is verified against the public certificate on your client.
  • DPoP binds the access token to a key on each REST API request. It is separate and still required for REST calls.

You may use the same PS256 (RSA) key pair for both, or separate keys. Whatever you choose, the private key never leaves your backend.

Next steps​