# 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](/docs/transformations) can decode the token, verify its signature and claims, and pass the result on as headers. A [Filter](/docs/filters) then drops requests whose token isn't valid, so your [Destination](/docs/destinations) 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](https://github.com/panva/jose) library against a JSON Web Key Set (JWKS)
* Drop requests with a missing or invalid token

## Prerequisites

* A [Connection](/docs/connections) 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:

```js
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](/docs/authentication#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.

```js
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](/docs/transformations#environment-variables) of the transformation:

| Algorithm | Variable | Value |
| --- | --- | --- |
| HS256 | `JWT_SECRET` | The shared secret |
| RS256, ES256 | `JWT_PUBLIC_JWK` | The 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](https://github.com/panva/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](https://esbuild.github.io/). See [bundle dependencies](/docs/transformations#bundle-dependencies) for background.

### 1. Install the dependencies

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

```

### 2. Write the transformation

Save this as `verify-jwt.js`:

```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.

### 3. Bundle it

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

```

The bundle is about 16 KB.

### 4. 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](/docs/transformations#create-a-transformation). To do it from the terminal, see [set it up with the CLI](#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:

```json
{
  "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](/docs/guides/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](/docs/transformations#transformation-issues), which is noisier when invalid tokens are expected, such as from scanners or expired sessions.

## Set it up with the CLI

The [Hookdeck CLI](/docs/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](#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

```bash
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:

```bash
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`:

```bash
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](#test-it), `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:

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

```text
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:

```bash
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:

```js
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](/docs/destinations#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](/docs/transformations#nodejs-compatibility).