Implementing client credentials grant flow with public/private key pair, client assertion, and JWKS in Node.js
This blog post will walk you through an implementation of the client credentials
grant flow using a client assertion
. We will build a system to create, sign, and validate JSON Web Tokens (JWTs) using public-private key pairs and JSON Web Key Sets (JWKS) in Node.js. We’ll use Node/Express for the server framework, Jose for JWT signing and validation, and Axios for HTTP requests. The system is composed of a bootstrapper and three microservices, orchestrated with Docker Compose for easy setup and execution.
The workflow looks as follows:
sequenceDiagram participant Client participant Auth participant Api activate Client Client->>Client: generate client_assertion Client->>Client: sign client_assertion with private key Client->>Auth: Exchange client assertion for access_token activate Auth Auth->>Auth: Verify client_assertion with public key Auth->>Auth: Generate access_token Auth->>Auth: Sign access_token with private key Auth->>Client: return access_token deactivate Auth Client->>Api: Get protected resource with access_token activate Api Api->>Auth: Get public key from JWKS activate Auth Auth->>Api: return public key deactivate Auth Api->>Api: Verify access_token with public key Api->>Client: Return protected resource deactivate Api deactivate Client
The codebase includes:
- Bootstrapper: Generates a public-private key pair and a JWKS.
- Auth Server: Exposes routes to serve the JWKS and issue signed access tokens.
- API Server: Protects a route, validating access tokens using the Auth server’s JWKS.
- Client: Signs a client assertion and exchanges it for an access token from the Auth server to access the API’s protected resource.
Below are step-by-step instructions, complete with sample code, to set up and run this system.
- Step 1: Project Setup
- Step 2: Bootstrapper - Generating Keys and JWKS
- Step 3: Auth Server - Serving JWKS and Signing JWTs
- Step 4: API Server - Protecting a Route with JWT Validation
- Step 5: Client - Fetching Token and Accessing Protected Resource
- Step 6: Docker Compose Setup
- Step 7: Running the System
- Step 8: Testing Manually
- Conclusion
Prerequisites
- Node.js (v18 or higher)
- Docker and Docker Compose
- Basic understanding of JWTs, public-private key cryptography, and Node.js.
Step 1: Project Setup
Create a project directory with the following structure (files will be added in the steps below):
node-jwks-demo/
├── bootstrapper/
│ ├── index.js
│ └── package.json
├── auth/
│ ├── index.js
│ └── package.json
├── api/
│ ├── index.js
│ └── package.json
├── client/
│ ├── index.js
│ └── package.json
├── docker-compose.yml
└── keys/
├── private.pem
├── public.pem
└── jwks.json
We’ll use Docker Compose to run all services. The keys/
directory will store the generated keys and JWKS.
Step 2: Bootstrapper - Generating Keys and JWKS
The Bootstrapper generates an RSA key pair and a JWKS file using the jose
library.
Code for Bootstrapper
Create bootstrapper/package.json
:
{
"name": "bootstrapper",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"jose": "^6.0.12"
},
"scripts": {
"start": "node index.js",
"lint": "eslint ."
},
"type": "module",
"devDependencies": {
"@eslint/js": "^9.31.0",
"eslint": "^9.31.0",
"globals": "^16.3.0",
"prettier": "3.6.2"
}
}
Create bootstrapper/index.js
:
import { writeFileSync } from "fs";
import { generateKeyPair, exportSPKI, exportPKCS8, exportJWK } from "jose";
// Define the path where keys will be saved
const path = "../keys/";
async function generateAndSaveKeys() {
// Generate a key pair (RSA or EC — we'll use RSA here)
const { publicKey, privateKey } = await generateKeyPair("RS256", {
modulusLength: 2048, // recommended key size
extractable: true,
});
// Export the public/private keys to PEM format
const publicPem = await exportKeyToPEM(publicKey);
const privatePem = await exportKeyToPEM(privateKey);
// Export the public key to JWK format
const jwk = await exportKeyToJWK(publicKey);
// 4. Optionally add desired metadata
jwk.use = "sig";
jwk.alg = "RS256";
jwk.kid = "client-key"; // optional, but recommended
// Wrap in JWKS format
const jwks = {
keys: [jwk],
};
// Write keys to files
writeFileSync(`${path}public_key.pem`, publicPem);
writeFileSync(`${path}private_key.pem`, privatePem);
writeFileSync(`${path}jwks.json`, JSON.stringify(jwks));
console.log(
`✅ Keys generated and saved to ${path} as public_key.pem and private_key.pem`
);
}
async function exportKeyToPEM(key) {
const pem = await exportSPKI(key).catch(() => exportPKCS8(key));
return pem;
}
async function exportKeyToJWK(key) {
const jwk = await exportJWK(key);
return jwk;
}
generateAndSaveKeys().catch(console.error);
Explanation
- The
jose
library generates an RSA key pair with the RS256 algorithm. - The public and private keys are exported in PEM format and saved to
keys/
. - The public key is converted to a JWK and wrapped in a JWKS object with a
kid
(key ID) for identification.
Step 3: Auth Server - Serving JWKS and Signing JWTs
The Auth server provides two endpoints:
/jwks.json
: Returns the JWKS./token
: Signs and returns a JWT.
Code for Auth Server
Create auth/package.json
:
{
"name": "auth",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"body-parser": "^2.2.0",
"express": "^5.1.0",
"jose": "^6.0.12"
},
"scripts": {
"start": "node index.js"
},
"type": "module",
"devDependencies": {
"@eslint/js": "^9.31.0",
"eslint": "^9.31.0",
"globals": "^16.3.0"
}
}
Create auth/index.js
:
import jwksjson from "../keys/jwks.json" with { type: "json" };
import express from "express";
import * as jose from "jose";
import fs from "fs";
import bodyParser from "body-parser";
const app = express();
const port = 3001;
// create application/x-www-form-urlencoded parser
const urlencodedParser = bodyParser.urlencoded();
const pathToPrivateKeyFile = new URL("../keys/private_key.pem", import.meta.url)
.pathname;
const privateKeyFile = fs.readFileSync(pathToPrivateKeyFile, "utf8");
const privateKey = await jose.importPKCS8(privateKeyFile, "RS256");
const pathToPublicKeyFile = new URL("../keys/public_key.pem", import.meta.url)
.pathname;
const publicKeyFile = fs.readFileSync(pathToPublicKeyFile, "utf8");
const publicKey = await jose.importSPKI(publicKeyFile, "RS256");
app.post("/token", urlencodedParser, async (req, res) => {
console.info("Received request to /token endpoint");
console.info("Validating request body...");
// Check to see if the request body is present
if (!req.body) {
console.error("Request body is missing");
return res.status(400).json({ error: "Request body is required" });
}
// Check to see if the client_id is present in the request body
if (!req.body.client_id && req.body.client_id !== "client") {
// ADD additional check for valid client_id here if needed
console.error("Missing or invalid client_id in request body");
return res.status(400).json({ error: "Missing or invalid client_id" });
}
// Check to see if the client_assertion_type is present in the request body
if (
!req.body.client_assertion_type &&
req.body.client_assertion_type !==
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
) {
console.error("Missing or invalid client_assertion_type in request body");
return res
.status(400)
.json({ error: "Missing or invalid client_assertion_type" });
}
// Check to see if the client_assertion is present in the request body
if (!req.body.client_assertion) {
console.error("Missing client_assertion in request body");
return res.status(400).json({ error: "Missing client_assertion" });
}
console.info("Request body validation successful");
const client_assertion = req.body.client_assertion;
console.log("client_assertion:", client_assertion);
// Verify the client_assertion
console.info("Verifying client_assertion...");
try {
const payload = await jose.jwtVerify(client_assertion, publicKey, {
issuer: "client",
audience: "auth",
});
console.info("client_assertion successfully verified");
console.log("Verified client_assertion payload:", payload);
console.info("Generating access token...");
const access_token = await new jose.SignJWT({
sub: payload.sub,
user: "exampleUser",
})
.setProtectedHeader({ alg: "RS256", kid: "client-key" })
.setIssuedAt()
.setIssuer("auth")
.setAudience("api")
.setExpirationTime("2h")
.sign(privateKey);
console.info("Access token generated successfully");
console.info("Returning access token to client...");
res.json({ token: access_token });
} catch (err) {
console.error("Invalid client_assertion:", err.message);
res.status(401).json({ error: "Invalid client_assertion" });
}
});
/**
* Endpoint to serve the JWKS (JSON Web Key Set) for public key retrieval.
* This is typically used for verifying JWTs (JSON Web Tokens).
*/
app.get("/.well-known/jwks.json", (req, res) => {
res.setHeader("Content-Type", "application/json");
res.send(jwksjson);
});
app.get("/health", (req, res) => {
res.status(200).json({ status: "ok" });
});
app.listen(port, () => console.log(`Auth server running on port ${port}`));
Explanation
- The
/.well-known/jwks.json
endpoint serves the JWKS file generated by the Bootstrapper. - The
/token
endpoint verifies aclient assertion
and then signs and returns anaccess token
with the private key, including a subject (sub
), scope, and expiration. - The
kid
in the JWT header matches the JWKS for validation.
Step 4: API Server - Protecting a Route with JWT Validation
The API server validates incoming JWTs using the Auth server’s JWKS.
Code for API Server
Create api/package.json
{
"name": "api",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"express": "^5.1.0",
"jose": "^6.0.12"
},
"scripts": {
"start": "node index.js"
},
"type": "module",
"devDependencies": {
"@eslint/js": "^9.31.0",
"eslint": "^9.31.0",
"globals": "^16.3.0"
}
}
Create api/index.js
:
import express from "express";
import * as jose from "jose";
const app = express();
const port = 3002;
const JWKS_URL = "http://auth:3001/.well-known/jwks.json";
console.info("Retrieving JWKS...");
const clientJWKSet = jose.createRemoteJWKSet(new URL(JWKS_URL));
console.info("JWKS successfully retrieved");
app.get("/protected", async (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res
.status(401)
.json({ error: "Missing or invalid Authorization header" });
}
const token = authHeader.split(" ")[1];
try {
const { payload } = await jose.jwtVerify(token, clientJWKSet, {
algorithms: ["RS256"],
});
res.json({ message: "Protected resource accessed", user: payload.sub });
} catch (error) {
console.error("JWT verification error:", error);
res.status(401).json({ error: "Invalid or expired token" });
}
});
app.listen(port, () => console.log(`API server running on port ${port}`));
Explanation
- The
/protected
endpoint checks for a Bearer token in the Authorization header. - The
jose
library’screateRemoteJWKSet
fetches the JWKS from the Auth server to validate the token. - If valid, the endpoint returns a success message with the token’s subject.
Step 5: Client - Fetching Token and Accessing Protected Resource
The Client exchanges a signed client assertion
for an access token
from the Auth server and uses it to access the API’s protected route.
Code for Client
Create client/package.json
:
{
"name": "client",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"axios": "^1.6.7"
},
"scripts": {
"start": "node index.js"
}
}
Create client/index.js
:
import axios from "axios";
import * as jose from "jose";
import fs from "fs";
async function runClient() {
console.info("Generating client assertion...");
const pathToPrivateKeyFile = new URL(
"../keys/private_key.pem",
import.meta.url
).pathname;
const privateKeyFile = fs.readFileSync(pathToPrivateKeyFile, "utf8");
const privateKey = await jose.importPKCS8(privateKeyFile, "RS256");
const now = Math.floor(Date.now() / 1000);
const clientAssertion = await new jose.SignJWT({ sub: "client" })
.setProtectedHeader({ alg: "RS256" })
.setIssuedAt(now)
.setIssuer("client")
.setAudience("auth")
.setExpirationTime("5m")
.sign(privateKey);
console.info("Client Assertion generated successfully");
console.log("Client Assertion:", clientAssertion);
try {
console.info(
"Exchanging client assertion for access_token from Auth server..."
);
const tokenResponse = await axios.post(
"http://auth:3001/token",
{
client_assertion: clientAssertion,
client_assertion_type:
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_id: "client",
},
{
headers: { "Content-Type": "application/x-www-form-urlencoded" },
}
);
const token = tokenResponse.data.token;
console.info("Access_token received successfully:");
console.log("Token:", token);
console.info("Accessing protected API...");
const apiResponse = await axios.get("http://api:3002/protected", {
headers: { Authorization: `Bearer ${token}` },
});
console.info("Protected API accessed successfully");
console.log("API Response received:", apiResponse.data);
} catch (error) {
console.error(
"Client error:",
error.response ? error.response.data : error.message
);
}
}
runClient();
Explanation
- The Client uses
jose
to sign aclient assertion
. - The Client uses
axios
to exchange theclient assertion
for anaccess token
from the Auth server’s/token
endpoint. - It then sends the
access token
in the Authorization header to the API’s/protected
endpoint.
Step 6: Docker Compose Setup
Create docker-compose.yml
in the root directory to orchestrate the services:
services:
bootstrapper:
build: ./bootstrapper
volumes:
- ./keys:/keys
command: npm start
profiles:
- bootstrap
auth:
build: ./auth
ports:
- "3001:3001"
volumes:
- ./keys:/keys
command: npm start
profiles:
- stack
api:
build: ./api
ports:
- "3002:3002"
volumes:
- ./keys:/keys
depends_on:
- auth
command: npm start
profiles:
- stack
client:
build: ./client
depends_on:
- api
volumes:
- ./keys:/keys
command: npm start
profiles:
- stack
Explanation
- Each service is built from its respective directory.
- The
keys/
directory is shared as a volume to persist keys and JWKS. - The
bootstrap
andstack
profiles are used to organize the components.
Step 7: Running the System
- Ensure Docker is running.
- In the
jwt-demo/
directory, run the following command to generate the keys:
docker-compose --profile bootstrap up --build --force-recreate
- In the
jwt-demo/
directory, run the following command to spin up the stack:
docker-compose --profile stack up --build --force-recreate
Expected Output
- The Bootstrapper logs:
Keys and JWKS generated successfully
. - The Auth server logs:
Auth server running on port 3001
- The API server logs:
API server running on port 3002
- The Client logs:
API Response: { message: 'Protected resource accessed' }
Step 8: Testing Manually
You can test the system using curl
or a tool like Postman:
Get the JWKS:
curl http://localhost:3001/jwks.json
Get a token:
curl http://localhost:3001/token
Access the protected resource:
curl -H "Authorization: Bearer <token>" http://localhost:3002/protected
Conclusion
This system demonstrates how to use public-private key pairs, JWKS and client assertion for secure JWT handling in a Node.js environment. The jose
library simplifies key management and JWT operations, while Docker Compose ensures easy setup. You can extend this by adding more robust error handling, token refresh mechanisms, or additional protected routes.
For the full codebase, check out the GitHub repository.