In this article
January 12, 2026
January 12, 2026

What is Private Key JWT: Deep dive into asymmetric client authentication

What asymmetric client authentication is, why it’s safer than client secrets, and how it works in practice.

In the OAuth 2.0 and OpenID Connect ecosystems, before an app gets issued a token it needs to prove that it is indeed the app it claims to be. Traditionally, this was done with a shared client_secret, but a stronger, asymmetric option exists: private_key_jwt.

At its heart, private_key_jwt is a JWT-based client authentication method where a client proves its identity by signing a JSON Web Token (JWT) using its private key, and the server verifies that signature using the client’s registered public key.

In this article, we will dive deep into how private_key_jwts work, how they improve security posture, and when you should use them.

The role of client authentication

Before dissecting private_key_jwt, let’s anchor why this exists.When a confidential client (like a backend service) exchanges an authorization code or requests a token, the OAuth server must ensure that this request really comes from that client. If identity could be spoofed here, a leaked authorization code could be exchanged for tokens by an attacker. Client authentication solves that problem.

The most common OAuth flow (the Authorization Code Flow) looks like this:

  1. The user signs in at the authorization server.
  2. The client (i.e., your app) receives an authorization code (a short-lived, one-time credential).
  3. The client sends that code to the token endpoint to exchange it for tokens (access token, and in OIDC, an ID token)

That last step is where client authentication comes in.

At the token endpoint, the authorization server needs to answer: "Is the caller exchanging this code actually the client it claims to be?"

Why? Because if the token endpoint accepted a code without authenticating the client, then possession of the code alone would be enough to mint tokens. That creates a problem. OAuth assumes that authorization codes can leak in many ways (misconfigured redirect URIs, logs, compromised frontends, etc.). So clients need to prove their identity.

For confidential clients, there are a few standard ways to prove who they are. The two you’ll most often encounter are:

  • client_secret: The client proves knowledge of a shared secret (usually via HTTP Basic auth or a form field). Same thing as using a password.
  • private_key_jwt: The client proves possession of a private key by signing a short-lived JWT that the server verifies with the client’s registered public key.

Choosing between these two changes your security posture and your operational experience: how you store credentials, how you rotate them, and how ugly it gets when something leaks.

Why not just a client secret?

A client_secret is basically “knowledge of a password” shared between the client and server. If that secret leaks (e.g., from logs, CI environments, or config files), anyone with it can impersonate the client. There’s no cryptographic binding between the entity that holds the secret and the act of signing a message.

That’s where private_key_jwt shines: possession of a private key becomes the proof of identity, and the public key (registered in advance) verifies that proof.

How private_key_jwt works

In practice, private_key_jwt is an asymmetric client authentication scheme with three core moving parts:

  1. Key pair generation and registration: The client generates a public/private key pair. The public key is shared with the authorization server (often in a JWKS document or via a jwks_uri). The private key stays secure on the client side.
  2. JWT assertion creation: When the client needs to authenticate, it builds a JWT assertion with specific claims that is then signed with the client’s private key. The claims included are:
    • iss (issuer): the client’s own client_id.
    • sub (subject): also the client_id.
    • aud (audience): the token endpoint URL (so the assertion is bound to that endpoint).
    • exp: expiry; typically very short (minutes).
    • jti: a unique identifier to prevent replay attacks.
  3. Token endpoint request: The client sends a token request like so:
	
POST /oauth/tokenContent-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<signed JWT here>
	

The authorization server verifies the signature with the public key it already has, checks that the aud matches its endpoint, that exp hasn’t passed, and the jti is fresh. If it all checks out, the client is authenticated.

Example: Generate a JWT assertion with Node

This code sample does two things:

  1. Creates a short-lived JWT signed with the client’s private key.
  2. Sends that JWT as a client_assertion when requesting a token.
	
// private-key-jwt-auth-code-end-to-end.js
// End-to-end Node example:
// 1) Redirect user to authorization endpoint to get an authorization code
// 2) Handle callback and exchange code for tokens using private_key_jwt
//
// npm i express jose
// Node 18+ recommended
//
// Env vars expected:
//   CLIENT_ID=...
//   AUTHORIZATION_ENDPOINT=https://idp.example.com/oauth/authorize
//   TOKEN_ENDPOINT=https://idp.example.com/oauth/token
//   REDIRECT_URI=http://localhost:3000/callback
//   PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
// Optional:
//   KEY_ID=your-kid
//   SCOPES="openid profile email"   (space-separated)
//   ISSUER=https://idp.example.com  (for discovery, if you want to verify id_token elsewhere)

import express from "express";
import crypto from "crypto";
import { SignJWT, importPKCS8 } from "jose";

const app = express();

const clientId = process.env.CLIENT_ID;
const authorizationEndpoint = process.env.AUTHORIZATION_ENDPOINT;
const tokenEndpoint = process.env.TOKEN_ENDPOINT;
const redirectUri = process.env.REDIRECT_URI;

const privateKeyPem = process.env.PRIVATE_KEY_PEM;
const kid = process.env.KEY_ID;

const scopes = process.env.SCOPES || "openid profile email";

if (!clientId || !authorizationEndpoint || !tokenEndpoint || !redirectUri || !privateKeyPem) {
  throw new Error(
    "Missing env vars: CLIENT_ID, AUTHORIZATION_ENDPOINT, TOKEN_ENDPOINT, REDIRECT_URI, PRIVATE_KEY_PEM"
  );
}

// Very small in-memory store for demo purposes only.
// In production: store per-user session (cookie session, Redis, etc).
const stateStore = new Set();

// --- Step A: Redirect user to get an authorization code ---
app.get("/login", (req, res) => {
  const state = crypto.randomUUID();
  stateStore.add(state);

  const params = new URLSearchParams({
    response_type: "code",
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: scopes,
    state,

    // If your IdP requires PKCE even for confidential clients, add:
    // code_challenge: "...",
    // code_challenge_method: "S256",
  });

  const url = `${authorizationEndpoint}?${params.toString()}`;
  res.redirect(url);
});

// --- Build private_key_jwt client assertion (for the token endpoint) ---
async function buildClientAssertion() {
  const alg = "RS256";
  const privateKey = await importPKCS8(privateKeyPem, alg);

  const now = Math.floor(Date.now() / 1000);
  const exp = now + 5 * 60; // 5 minutes

  return await new SignJWT({})
    .setProtectedHeader({ alg, ...(kid ? { kid } : {}) })
    .setIssuer(clientId)        // iss
    .setSubject(clientId)       // sub
    .setAudience(tokenEndpoint) // aud must exactly match the token endpoint
    .setIssuedAt(now)
    .setExpirationTime(exp)
    .setJti(crypto.randomUUID())
    .sign(privateKey);
}

// --- Step B: Handle callback and exchange code for tokens ---
app.get("/callback", async (req, res) => {
  const { code, state, error, error_description } = req.query;

  if (error) {
    return res.status(400).send(`Authorization error: ${error} ${error_description || ""}`);
  }
  if (!code || !state || typeof state !== "string") {
    return res.status(400).send("Missing code or state");
  }
  if (!stateStore.has(state)) {
    return res.status(400).send("Invalid state");
  }
  stateStore.delete(state);

  try {
    const clientAssertion = await buildClientAssertion();

    const body = new URLSearchParams({
      grant_type: "authorization_code",
      code: String(code),
      redirect_uri: redirectUri,

      // private_key_jwt client authentication
      client_id: clientId,
      client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
      client_assertion: clientAssertion,
    });

    const tokenRes = await fetch(tokenEndpoint, {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body,
    });

    const tokens = await tokenRes.json().catch(() => ({}));

    if (!tokenRes.ok) {
      return res
        .status(400)
        .json({ message: "Token exchange failed", status: tokenRes.status, tokens });
    }

    // tokens may include: access_token, id_token, refresh_token, expires_in, token_type, scope
    res.json({ message: "✅ Success", tokens });
  } catch (e) {
    res.status(500).json({ message: "Unexpected error", error: e.message });
  }
});

app.listen(3000, () => {
  console.log("Listening on http://localhost:3000");
  console.log("Visit http://localhost:3000/login to start the flow.");
});
	

The example assumes you already generated a key pair and registered the public key with the authorization server.

The kid that you are expected to fill in is the identifier you choose when you create the key pair. It is included in the JWT header so the authorization server knows which public key to use when verifying the signature. In practice, a kid is often a UUID, a hash of the public key, or a versioned identifier like key-2024-01. Whatever you choose, the same kid must appear in both the JWT header and the public key entry you register (for example, in your JWKS).

When you rotate keys, you generate a new key pair with a new kid, publish the new public key alongside the old one, and then start signing assertions with the new private key. The kid lets the server distinguish between old and new keys during that transition.

What makes private_key_jwt stronger than client_secret

The security benefits come from asymmetric cryptography and short-lived assertions:

  • No secret in transit: With private_key_jwt, the client never sends a reusable credential like a client_secret over the wire. Instead, it sends a one-time, signed assertion whose validity is tightly bounded by time and audience. Even if the request is logged, intercepted, or accidentally persisted somewhere unfortunate, there’s no long-lived secret an attacker can extract and reuse later.
  • Unforgeable proof: The JWT signature is created using the client’s private key, which by design never leaves the client’s control. The authorization server verifies this signature using the corresponding public key it already trusts. Without possession of the private key, an attacker cannot mint a valid assertion, no matter how well they understand the protocol or observe previous requests.
  • Replay resistance: Assertions are intentionally short-lived, often expiring in just a few minutes, and include a unique jti (JWT ID). This combination means that even if an assertion is captured, its usefulness window is extremely narrow. If the server tracks jti values, the same assertion can’t be replayed at all, turning a potentially serious interception into a harmless, expired artifact.
  • Reduced blast radius: Public keys are meant to be shared, cached, and even leaked; they carry no signing power on their own. If a public key is exposed, nothing breaks. The private key, meanwhile, stays anchored to the client environment and never crosses a network boundary. Compared to symmetric secrets, this sharply limits the impact of accidental disclosure and makes compromises easier to contain and recover from.

Conceptually, you’re not saying “I know a secret”; you’re saying “I held the key and generated this signed proof right now.”

That’s more like showing a digital badge signed by your personal hardware key than whispering a password.

Developer considerations

Engineers should think about a few practical themes when using private_key_jwt:

  • Key lifecycle: Private keys are not “set and forget” assets. You need a rotation strategy that lets you introduce a new key, start signing assertions with it, and retire the old one without breaking in-flight requests. In practice, this usually means publishing multiple public keys at once (via JWKS), using kid values to signal which key signed which JWT, and overlapping validity windows so nothing falls off a cliff during rotation.
  • Clock skew: private_key_jwt lives and dies by time-based claims like iat and exp. If the client and authorization server disagree about what time it is, perfectly valid assertions can be rejected as “not yet valid” or “already expired.” Keeping clocks synchronized with NTP and allowing a small validation leeway on the server side helps prevent time drift from turning into hard-to-debug auth failures.
  • Key distribution: Manually copying public keys around does not scale and invites subtle, expensive mistakes. Publishing keys via a JWKS endpoint and letting authorization servers fetch and cache them creates a clean, automatable path for key updates. This also makes rotation predictable: add a new key to JWKS, wait for propagation, then start using it.
  • Libraries: Most modern JWT libraries make asymmetric signing straightforward, which removes a lot of cryptographic footguns. What they don’t do is decide what your JWT should say. Engineers still need to ensure required claims are present, values like aud exactly match expectations, and headers like kid align with the server’s key configuration. The sharp edges tend to live in the semantics, not the math.
  • Compliance: Some security frameworks explicitly require private_key_jwt because it enforces asymmetric client authentication and short-lived proofs. Financial-grade APIs (FAPI) are a common example, where the threat model assumes secrets will eventually leak and designs accordingly. In those environments, private_key_jwt isn’t just a nice upgrade; it’s the baseline you build everything else on.

When to use private_key_jwt

If your integration will live in a trusted backend (machine-to-machine APIs, server-side apps, or platforms that can safeguard a private key) private_key_jwt is nearly always worth considering over a static client secret. The stronger cryptographic tether between the identity of the caller and the proof presented adds resilience against leakage and impersonation.

However, if you’re building a quick prototype or an environment where the key management costs outweigh the risk, traditional client_secret methods remain fully supported and simpler to adopt.

Final thoughts

private_key_jwt isn’t about making OAuth more complicated for the sake of it. It’s about aligning client authentication with the reality that secrets leak, logs get copied, and credentials tend to outlive the environments they were meant for. By shifting from shared secrets to asymmetric proof, you reduce the long-term risk surface and make authentication failures sharper, earlier, and easier to reason about.

That said, this isn’t a universal mandate. private_key_jwt asks more of you operationally: key generation, storage, rotation, and time discipline. For production systems, especially those handling sensitive data or operating at scale, that tradeoff is usually well worth it. For early-stage prototypes or low-risk integrations, simplicity still has value.

The important thing is intentionality. Understanding howprivate_key_jwt works and why it’s stronger lets you choose it when the security model calls for it, rather than stumbling into it later under pressure. In OAuth, as in engineering more broadly, the strongest systems are rarely the ones with the fewest parts, but the ones where each part has a clear job and a well-defined failure mode.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.