Decode and Verify JWTs with a Transformation

Many services authenticate the requests they send with a JSON Web Token (JWT) in the Authorization header. A Transformation can decode the token, verify its signature and claims, and pass the result on as headers. A Filter then drops requests whose token isn't valid, so your Destination only receives authenticated events and doesn't need its own JWT handling.

This guide shows how to:

  • Read the claims of a token without verifying it
  • Verify HS256, RS256 and ES256 tokens with the built-in crypto module, without dependencies
  • Verify tokens with the jose library against a JSON Web Key Set (JWKS)
  • Drop requests with a missing or invalid token

Prerequisites

  • A Connection whose source receives requests with an Authorization: Bearer <token> header
  • The secret or public key that the tokens are signed with

Read the claims

A JWT has three base64url-encoded parts separated by dots: a header, a payload of claims, and a signature. This transformation decodes the header and payload and copies a few claims into headers:

addHandler('transform', (request) => {
  const token = request.headers['authorization']?.replace(/^Bearer\s+/i, '');
  if (!token) return request;

  const [header, payload] = token
    .split('.')
    .slice(0, 2)
    .map((part) => JSON.parse(Buffer.from(part, 'base64url').toString()));

  request.headers['x-jwt-alg'] = header.alg;
  request.headers['x-jwt-sub'] = String(payload.sub);
  request.headers['x-jwt-iss'] = String(payload.iss);
  return request;
});

Decoding doesn't check the signature, so anyone can send a token with any claims. Only use decoded claims for routing or display when the request is already authenticated another way, such as with source authentication. Otherwise, verify the token.

Verify with Node.js crypto

This transformation verifies the token with the built-in crypto module, so you can paste it into the editor as is. It checks the signature, the expiry (exp), the not-before time (nbf), the issuer (iss) and the audience (aud). It then sets x-jwt-verified to true or false, so a filter can drop invalid requests.

const { createHmac, createPublicKey, timingSafeEqual, verify } = require('crypto');

// Set these to match the tokens you expect.
const ALGORITHM = 'HS256'; // HS256, RS256 or ES256
const ISSUER = 'https://auth.example.com/';
const AUDIENCE = 'orders-webhook';
const CLOCK_TOLERANCE = 30; // seconds

const VERIFIERS = {
  HS256: (data, signature) => {
    const expected = createHmac('sha256', process.env.JWT_SECRET).update(data).digest();
    return signature.length === expected.length && timingSafeEqual(signature, expected);
  },
  RS256: (data, signature) => verify('sha256', data, publicKey(), signature),
  ES256: (data, signature) =>
    verify('sha256', data, { key: publicKey(), dsaEncoding: 'ieee-p1363' }, signature),
};

const publicKey = () =>
  createPublicKey({ key: JSON.parse(process.env.JWT_PUBLIC_JWK), format: 'jwk' });

const decode = (part) => JSON.parse(Buffer.from(part, 'base64url').toString());

function verifyJwt(token) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('malformed token');
  const [header, payload, signature] = parts;

  // Never trust the algorithm in the token: pin it.
  if (decode(header).alg !== ALGORITHM) throw new Error('unexpected algorithm');
  const data = Buffer.from(`${header}.${payload}`);
  if (!VERIFIERS[ALGORITHM](data, Buffer.from(signature, 'base64url'))) {
    throw new Error('invalid signature');
  }

  const claims = decode(payload);
  const now = Math.floor(Date.now() / 1000);
  if (claims.exp !== undefined && now > claims.exp + CLOCK_TOLERANCE) throw new Error('token expired');
  if (claims.nbf !== undefined && now < claims.nbf - CLOCK_TOLERANCE) throw new Error('token not yet valid');
  if (claims.iss !== ISSUER) throw new Error('unexpected issuer');
  if (![].concat(claims.aud).includes(AUDIENCE)) throw new Error('unexpected audience');
  return claims;
}

addHandler('transform', (request) => {
  const token = request.headers['authorization']?.replace(/^Bearer\s+/i, '');
  try {
    if (!token) throw new Error('missing token');
    const claims = verifyJwt(token);
    request.headers['x-jwt-verified'] = 'true';
    request.headers['x-jwt-sub'] = String(claims.sub);
  } catch (error) {
    request.headers['x-jwt-verified'] = 'false';
    request.headers['x-jwt-error'] = error.message;
  }
  return request;
});

Store the key in an environment variable of the transformation:

AlgorithmVariableValue
HS256JWT_SECRETThe shared secret
RS256, ES256JWT_PUBLIC_JWKThe public key as a JSON Web Key (JWK), on a single line, such as {"kty":"RSA","n":"…","e":"AQAB"}

Identity providers publish their public keys as a JWKS, a JSON document with a keys array, usually at a URL ending in /.well-known/jwks.json. Copy the key whose kid matches the kid in your tokens' header.

ALGORITHM is fixed in the code on purpose. If the transformation used the alg from the token header, a sender could choose none, or sign with HS256 using your public key as the secret, and forge a token that passes.

For a token that fails verification, your destination receives headers like these:

x-jwt-verified: false
x-jwt-error: token expired

Verify with jose

jose supports the standard JWT algorithms, selects the key from a JWKS by its kid, and validates the claims for you. Transformations can only import Node.js built-in modules, so you bundle the library into your code with esbuild. See bundle dependencies for background.

Install the dependencies

mkdir jwt-transformation && cd jwt-transformation
npm init -y
npm install jose
npm install --save-dev esbuild

Write the transformation

Save this as verify-jwt.js:

import { createLocalJWKSet, jwtVerify } from 'jose';

const jwks = createLocalJWKSet(JSON.parse(process.env.JWT_JWKS));

export default {
  async transform(request) {
    const token = request.headers['authorization']?.replace(/^Bearer\s+/i, '');
    try {
      if (!token) throw new Error('missing token');
      const { payload } = await jwtVerify(token, jwks, {
        issuer: 'https://auth.example.com/',
        audience: 'orders-webhook',
        algorithms: ['RS256', 'ES256'],
        clockTolerance: 30,
      });
      request.headers['x-jwt-verified'] = 'true';
      request.headers['x-jwt-sub'] = String(payload.sub);
    } catch (error) {
      request.headers['x-jwt-verified'] = 'false';
      request.headers['x-jwt-error'] = error.code ?? error.message;
    }
    return request;
  },
};

Set the JWT_JWKS environment variable to the contents of your provider's JWKS document, on a single line. Transformations can't make network requests, so createRemoteJWKSet doesn't work. When the provider rotates its keys, update the variable.

The algorithms option plays the same role as ALGORITHM in the previous section: jose rejects tokens signed with any other algorithm, including none. Keep only the algorithms your issuer uses.

Bundle it

npx esbuild verify-jwt.js --bundle --format=esm --platform=node --minify --outfile=dist/verify-jwt.js

The bundle is about 16 KB.

Add it to your connection

Paste the contents of dist/verify-jwt.js into a new transformation on your connection, and set JWT_JWKS in its variables. See create a transformation. To do it from the terminal, see set it up with the CLI.

When a token fails verification, x-jwt-error holds a jose error code such as ERR_JWT_EXPIRED, ERR_JWS_SIGNATURE_VERIFICATION_FAILED, ERR_JWT_CLAIM_VALIDATION_FAILED or ERR_JOSE_ALG_NOT_ALLOWED. ERR_JWKS_NO_MATCHING_KEY means no key in JWT_JWKS has the token's kid, which usually means the issuer rotated its keys and the variable needs updating.

Drop requests with an invalid token

Add a filter after the transformation on the same connection, with this schema on the Headers tab:

{
  "x-jwt-verified": "true"
}

Requests with a missing, expired or forged token are filtered out, and your destination only receives verified events. To see why a request was filtered, open the transformation's executions in the dashboard: each one shows the x-jwt-error header it set. Rules run in the order they appear on the connection, so the transformation must come before the filter. See how to order transformations and filters.

To fail the event instead, throw an error from the transformation rather than setting x-jwt-verified. The transformation fails and opens a transformation issue, which is noisier when invalid tokens are expected, such as from scanners or expired sessions.

Set it up with the CLI

The Hookdeck CLI can create the transformation and add it to a connection, followed by the filter. These examples use the jose version from verify with jose, with the issuer's JWKS saved as jwks.json. For the built-in version, set JWT_SECRET or JWT_PUBLIC_JWK instead of JWT_JWKS.

Pass variables as JSON, as shown below. The --env flag of transformation upsert takes KEY=value pairs separated by commas, so it can't hold a JWKS or a JWK. Variables belong to the transformation, so every connection that uses it gets the same values.

New connection

hookdeck gateway transformation upsert verify-jwt --code-file dist/verify-jwt.js

hookdeck gateway connection upsert orders-jwt \
  --source-name orders --source-type WEBHOOK \
  --destination-name orders-api --destination-type HTTP --destination-url https://api.example.com/orders \
  --rule-transform-name verify-jwt \
  --rule-transform-env "$(jq -c '{JWT_JWKS: (. | tojson)}' jwks.json)" \
  --rule-filter-headers '{"x-jwt-verified": "true"}'

The connection runs the transformation first, then the filter.

Existing connection

The --rule-* flags replace all of a connection's rules, so on a connection that already has rules, such as retries or other filters, use --rules-file with the full list instead. This adds the transformation and the filter in front of the existing rules, and creates or updates the verify-jwt transformation with its code and variables:

hookdeck gateway connection get orders-jwt --output json > connection.json

jq -c --rawfile code dist/verify-jwt.js --slurpfile jwks jwks.json '
  [
    {type: "transform", transformation: {name: "verify-jwt", code: $code, env: {JWT_JWKS: ($jwks[0] | tojson)}}},
    {type: "filter", headers: {"x-jwt-verified": "true"}}
  ] + .rules' connection.json > rules.json

hookdeck gateway connection upsert orders-jwt --rules-file rules.json

Run it once: running it again adds a second copy of both rules. To update the JWKS later, edit the transformation's variables in the dashboard.

Test the transformation

Run the transformation, with its variables, against a sample request. Use the transformation ID from hookdeck gateway transformation get verify-jwt:

hookdeck gateway transformation run --id trs_xxxxxxxx \
  --request "{\"headers\": {\"authorization\": \"Bearer $TOKEN\"}, \"body\": {}}"

The output shows the request with the x-jwt-verified and x-jwt-sub or x-jwt-error headers set. After you send test requests, hookdeck gateway event list --source-id src_xxxxxxxx lists events only for the requests with a valid token.

Set it up with an AI agent

A coding agent such as Claude Code or Cursor can do all of this with the Hookdeck CLI. Install the Hookdeck agent skills so it knows the CLI and the event gateway:

npx skills add hookdeck/agent-skills --skill event-gateway

Then give it a prompt like this one, with your own source, destination, issuer and audience:

Use the Hookdeck CLI to verify JWTs on requests to my "orders" source before
they reach my "orders-api" destination. Follow
https://hookdeck.com/docs/guides/how-to-decode-and-verify-jwts.

- Tokens arrive as "Authorization: Bearer <token>". They're signed with RS256
  by https://auth.example.com/ for the audience orders-webhook. The issuer's
  JWKS is in ./jwks.json.
- Use the jose version of the transformation, bundled with esbuild, with
  algorithms set to RS256 only.
- Add the transformation and the x-jwt-verified filter to my existing
  connection, and keep its other rules: follow the guide's steps for an
  existing connection.
- Before changing my connection, test on a temporary connection with a Mock
  API destination (--destination-type MOCK_API) and a copy of the
  transformation under another name, with a key pair and JWKS you generate.
  Check that a valid token produces an event, and that expired, wrongly signed
  and missing tokens are filtered. Then delete the temporary connection,
  source, destination and transformation.
- Don't change any other connection.

The agent can't sign tokens with your issuer's private key, so the prompt has it test with its own key pair on a temporary connection before it changes yours. The test uses its own copy of the transformation because variables belong to the transformation.

Test it

Built-in version with HS256

Create a test token with Node.js and send it to your source:

TOKEN=$(node -e "
const { createHmac } = require('crypto');
const b64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
const now = Math.floor(Date.now() / 1000);
const data = b64({ alg: 'HS256', typ: 'JWT' }) + '.' + b64({
  sub: 'user_123', iss: 'https://auth.example.com/', aud: 'orders-webhook', exp: now + 300,
});
console.log(data + '.' + createHmac('sha256', process.env.JWT_SECRET).update(data).digest('base64url'));
")

curl -X POST "https://hkdk.events/src_xxxxxxxx" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"order_id": "ord_123"}'

Run it with JWT_SECRET set to the same secret as the transformation's variable. Your destination receives the request with x-jwt-verified: true and x-jwt-sub: user_123. Send the request again without the Authorization header, and the filter drops it.

jose version with a JWKS

You can't sign test tokens with your issuer's private key, so generate a test key pair. Save this as make-test-token.mjs in the folder where you installed jose:

import { exportJWK, generateKeyPair, SignJWT } from 'jose';
import fs from 'fs';

const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true });
const jwk = { ...(await exportJWK(publicKey)), kid: 'test-1', alg: 'RS256' };
fs.writeFileSync('test-jwks.json', JSON.stringify({ keys: [jwk] }));

const token = await new SignJWT({ sub: 'user_123' })
  .setProtectedHeader({ alg: 'RS256', kid: 'test-1' })
  .setIssuer('https://auth.example.com/')
  .setAudience('orders-webhook')
  .setExpirationTime('5m')
  .sign(privateKey);
console.log(token);

Run TOKEN=$(node make-test-token.mjs), then use test-jwks.json as JWT_JWKS on a test copy of the transformation, such as on a connection with a Mock API destination, and send the token as above. Don't add the test key to your real JWKS.

Limits and pitfalls

  • Keys in variables, not URLs. Transformations can't reach the network, so they can't fetch a JWKS or call a token introspection endpoint. Store keys in environment variables, and update them when the issuer rotates its keys.
  • The token stays on the request. Verifying the token doesn't remove the Authorization header. If your destination shouldn't receive the token, delete it with delete request.headers['authorization'] after verifying it.
  • Clock tolerance. The issuer's clock and Hookdeck's clock can differ by a few seconds. CLOCK_TOLERANCE and clockTolerance allow 30 seconds either way when checking exp and nbf.
  • Verification happens once. The transformation checks the token when the event is created. Retries and replays deliver the same event without checking it again, even after the token expires.
  • Encrypted tokens (JWE). Tokens with five parts instead of three are encrypted. jose can decrypt them with jwtDecrypt if you store the decryption key in a variable.

For other modules and libraries you can use, see Transformation Node.js compatibility.