INITIALIZING
Technical

Duende (Identity Server), Certificates and Postman. What could go wrong? Signed JWT Requests the "easy" way. Part 02

Duende (Identity Server), Certificates and Postman. What could go wrong? Signed JWT Requests the "easy" way. Part 02

When requesting an OAuth bearer token for machine-to-machine communication (usually to talk to an API), the lazy path is client credentials with just a client ID and secret. It works. It's also not great, security-wise — a static secret sitting in a config file is basically a Post-it note that says “please steal me eventually.”

Enter the signed JWT request — the “yes, it's more setup, but your future self will thank you” option.

According to Wikipedia:

“JSON Web Token is a proposed Internet standard for creating data with optional signature and/or optional encryption whose payload holds JSON that asserts some number of claims. The tokens are signed either using a private secret or a public/private key.”

A JWT is three parts glued together with dots, which is either elegant or mildly unsettling depending on your mood that day:

const token = base64urlEncoding(header) + '.' + base64urlEncoding(payload) + '.' + base64urlEncoding(signature)
  • Header: the cryptographic algorithm (RS256, HMAC, JWA, take your pick).
  • Payload: the claims — in our case, the parameters that make up the request itself.
  • Signature: header + payload, signed with a secret, so nobody downstream can quietly edit the middle part and pretend nothing happened.

We'll build these requests in Postman, against the same self-hosted Duende server from Part 01 — go grab that first if you haven't already, this post assumes it's already running.

Postman Environments

Start by creating a new environment plus a couple of global variables.

First global: the jsrsasign-js library, which does the actual signing and encoding heavy lifting so we don't have to hand-roll RSA math in a Postman script box like maniacs. Grab the minified source from jsrsasign on GitHub — specifically jsrsasign-all-min.js — and paste its full contents into the global variable's current value.

Next, the environment variables the collection actually needs:

  • idpServer — where you're requesting tokens from. On Duende, something like https://yourduendedomain.com/connect/token.
  • clientId — the client ID from Part 01. We used myclient, because naming things is hard and this is a demo.
  • privateKey — pull this from the certificate using the Certificate Reader tool. Copy the private RSA key value in here. Guard this one like it's the nuclear codes, because functionally, it kind of is.
  • iss and sub — both just match your client ID.
  • aud — the intended audience, same value as idpServer.
  • clientAssertionType — always urn:ietf:params:oauth:client-assertion-type:jwt-bearer. This is what tells the IDP “hey, I'm using a signed assertion, not a plain secret, please be impressed.”
  • alg — the signing algorithm, RS256 for us.
  • scope — optional, controlled by the IDS. Ours is api.readonly; leave it blank and the IDS falls back to its default.
  • clientAssertion — a calculated field. Don't touch it, the script fills it in for you.
  • bearerToken — where the actual result lands after a successful request.

Don't worry about memorizing all of that — the environment file is attached below, fully pre-built with every variable and its description, ready to import.

Postman Collection

The request itself is a POST, body type x-www-form-urlencoded, with:

  • client_assertion_typeurn:ietf:params:oauth:client-assertion-type:jwt-bearer, pulled from the variable.
  • client_id — self-explanatory, thankfully.
  • grant_typeclient_credentials.
  • client_assertion — the calculated header + payload + signature value.
  • scope — the requested scope, optional.

The real work happens in the pre-request and post-request scripts.

Pre-request script — imports the signing library, reads the environment variables, builds the JWT header and payload, signs it, and stashes the result in clientAssertion:

//import jwt signing library
var navigator = {};
var window = {};
eval(pm.globals.get("jsrsasign-js"));

//Obtain environment variables
var idp = pm.environment.get('idpServer') || '';
var iss = pm.environment.get('iss') || '';
var sub = pm.environment.get('sub') || '';
var aud = pm.environment.get('aud') || '';
var alg = pm.environment.get('alg') || '';
var privateKey = pm.environment.get('privateKey') || '';

// Set headers for JWT
var header = {
	'typ': 'JWT',
	'alg': `${alg}`,
};

// Prepare timestamp in seconds
var currentTimestamp = Math.floor(Date.now() / 1000)

// Set body data
var data = {
	'iss': `${iss}`,	
    'sub': `${sub}`,
	'aud': `${aud}`,
	'exp': currentTimestamp + 300, // expiry time is 300 seconds from time of creation
	'jti': `${uuidv4()}`
}

data = addIAT(data);

var keyObject = KEYUTIL.getKey(privateKey);
var jwtSigned = KJUR.jws.JWS.sign(header.alg, JSON.stringify(header), JSON.stringify(data), keyObject);
console.log('Signed and encoded JWT', jwtSigned);
pm.environment.set('clientAssertion', jwtSigned);

//create guid
function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

//create iat
function addIAT(request) {
    var iat = Math.floor(Date.now() / 1000) + 257;
    data.iat = iat;
    return data;
}

That jti field is a fresh GUID on every request, so nobody can capture one signed request and lazily replay it forever — a small touch, but the kind of thing that separates “secure” from “secure until someone notices.”

Post-request script — grabs the bearer token out of the response and stores it for the next call:

const jsonResponse = pm.response.json();
pm.environment.set("bearerToken", jsonResponse.access_token);

Hit send, and if everything's wired up right, bearerToken now holds a freshly minted access token — no client secret sitting around anywhere, just a signed assertion that proves you are who you say you are, cryptographically, like a much nerdier wax seal.

You don't have to build any of this by hand — I've attached both files below:

Import both into Postman, drop your own privateKey and clientId values into the environment, hit send, and you've got a working signed-JWT token flow without writing a line of it yourself.

Next post: doing the same thing in C# code. Happy coding!!!