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
joselibrary 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.jsonendpoint serves the JWKS file generated by the Bootstrapper. - The
/tokenendpoint verifies aclient assertionand then signs and returns anaccess tokenwith the private key, including a subject (sub), scope, and expiration. - The
kidin 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
/protectedendpoint checks for a Bearer token in the Authorization header. - The
joselibrary’screateRemoteJWKSetfetches 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
joseto sign aclient assertion. - The Client uses
axiosto exchange theclient assertionfor anaccess tokenfrom the Auth server’s/tokenendpoint. - It then sends the
access tokenin the Authorization header to the API’s/protectedendpoint.
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
bootstrapandstackprofiles 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.