Scoping JWT claims for webhook authorization

The signature verified, the key was found in the JWKS, exp is in the future — and the request is still not necessarily allowed. A caller holding a perfectly valid token from your trusted issuer can be asking you to apply a subscription.cancelled event to a tenant it has no relationship with, and every check described in JWT-Based Webhook Auth will pass while it happens. Signature validity answers who sent this; it says nothing about what they may send, and on whose behalf. This page covers the second question: designing a claim set that carries an authorization decision, and enforcing it at the handler. It assumes you already run the verification pipeline in Validating JWT tokens in webhook payloads, and that your key material rotates as described in Rotating JWT signing keys with JWKS.

The failure this prevents is confused-deputy access in multi-tenant systems. A partner integration issued a token for tenant A discovers that your handler reads the tenant from the request body rather than from the token, and now writes to tenant B. Nothing is forged, no key leaked, no signature is wrong. The authorization decision simply was never made.

Authentication and authorization gates A signed webhook token passes a signature gate, then an identity gate on issuer and audience, then an authorization gate on tenant and event scope before the handler runs. Signed webhook JWT Gate 1 — authentication signature verifies against the JWKS key named by kid fail: 401 Gate 2 — identity iss and aud match one exact allowlist entry fail: 401 Gate 3 — authorization scope grants this event type for this tenant fail: 403 Handler runs bound to the tenant named in the token
Gates 1 and 2 establish identity and return 401; gate 3 is the authorization decision and returns 403 — collapsing them into one check is what lets a valid token act outside its remit.

Prerequisites

Step 1: Design the claim set around tenant and event scope

Keep the claim contract small and closed. Every claim you accept is something you must validate, and any claim you read but do not validate is an injection point. Five claims plus the standard temporal ones cover multi-tenant webhook authorization completely.

Webhook token claim anatomy Six claim lines from a scoped webhook JWT, each paired with the authorization question that claim answers. Claim set for a scoped webhook sender token "iss": "https://events.acme.io" which key set may have signed this "aud": "https://api.corp/hooks" which endpoint may accept it "sub": "sub_9f21c4" which subscription it acts for "tid": "tenant_eu_314" the only tenant it may touch "scp": "payment.* refund.created" event types it may deliver "exp": 1750000300, "jti": "d41f" how long, and once per identifier Every claim above is validated; any claim not listed here is ignored, never read.
Each claim answers exactly one authorization question, which is what makes the handler decision auditable rather than a pile of conditionals.
Claim Question it answers If you omit or ignore it
iss Which key set is allowed to have signed this token Any issuer you trust for anything can act as any other
aud Which of your endpoints may accept the token A token minted for the sandbox endpoint is replayable at production
tid Which tenant’s data this delivery may touch Cross-tenant writes whenever the payload carries the tenant id
scp Which event types this caller may deliver A read-only integration can push account.deleted
jti + exp How long the grant lives and whether it is single use A captured token stays usable for its full lifetime

Parse the claims into a typed shape and reject anything that does not conform. Do not use the raw JWTPayload downstream — an unknown-typed bag of claims invites the exact “read it but never check it” bug this design exists to prevent.

export interface WebhookGrant {
  readonly issuer: string;
  readonly audience: string;
  readonly subscriptionId: string;
  readonly tenantId: string;
  readonly scopes: readonly string[];
  readonly tokenId: string;
}

const ID_PATTERN = /^[a-z0-9_-]{4,64}$/i;
const SCOPE_PATTERN = /^[a-z0-9_]+(\.[a-z0-9_]+|\.\*)*$/i;

export function toGrant(payload: Record<string, unknown>): WebhookGrant {
  const str = (key: string): string => {
    const value = payload[key];
    if (typeof value !== 'string' || value.length === 0) {
      throw new AuthorizationError(`ERR_CLAIM_MISSING:${key}`);
    }
    return value;
  };

  const subscriptionId = str('sub');
  const tenantId = str('tid');
  const tokenId = str('jti');
  if (!ID_PATTERN.test(subscriptionId) || !ID_PATTERN.test(tenantId)) {
    throw new AuthorizationError('ERR_CLAIM_MALFORMED');
  }

  const scopes = str('scp').split(' ').filter(Boolean);
  if (scopes.length === 0 || !scopes.every((s) => SCOPE_PATTERN.test(s))) {
    throw new AuthorizationError('ERR_SCOPE_MALFORMED');
  }

  return {
    issuer: str('iss'),
    audience: str('aud'),
    subscriptionId,
    tenantId,
    scopes,
    tokenId,
  };
}

export class AuthorizationError extends Error {}

Engineering Note: Use a space-delimited scp string rather than a JSON array. It matches OAuth 2.0 convention, keeps the token compact when a subscription covers thirty event types, and removes an entire class of “is it a string or an array this time” parsing bugs.

Step 2: Pin issuer and audience to exact allowlists

aud is the claim that stops a token minted for one of your endpoints being replayed at another. It only works if you compare it to a single exact value — the URL of this endpoint — rather than to a list of everything you own. The same applies to iss: each trusted issuer gets its own configuration entry fixing its JWKS URI, its permitted algorithms, and the audience value it is allowed to assert.

import { createRemoteJWKSet, jwtVerify } from 'jose';

interface IssuerConfig {
  readonly jwksUri: URL;
  readonly algorithms: readonly string[];
  readonly audience: string; // exactly this endpoint, not a list
  readonly maxTokenLifetimeSeconds: number;
}

const ISSUERS: ReadonlyMap<string, IssuerConfig> = new Map([
  [
    'https://events.acme.io',
    {
      jwksUri: new URL('https://events.acme.io/.well-known/jwks.json'),
      algorithms: ['ES256'],
      audience: 'https://api.corp.example/hooks/acme',
      maxTokenLifetimeSeconds: 300,
    },
  ],
]);

const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();

function jwksFor(config: IssuerConfig) {
  const key = config.jwksUri.toString();
  let set = jwksCache.get(key);
  if (!set) {
    set = createRemoteJWKSet(config.jwksUri, {
      cacheMaxAge: 600_000,
      cooldownDuration: 30_000,
      timeoutDuration: 5_000,
    });
    jwksCache.set(key, set);
  }
  return set;
}

export async function authenticate(token: string, expectedIssuer: string): Promise<WebhookGrant> {
  const config = ISSUERS.get(expectedIssuer);
  if (!config) throw new AuthorizationError('ERR_ISSUER_NOT_TRUSTED');

  const { payload } = await jwtVerify(token, jwksFor(config), {
    issuer: expectedIssuer,
    audience: config.audience,
    algorithms: [...config.algorithms],
    clockTolerance: 30,
    requiredClaims: ['iss', 'aud', 'sub', 'exp', 'jti'],
  });

  // Reject long-lived tokens even when the signature is valid.
  const lifetime = (payload.exp ?? 0) - (payload.iat ?? 0);
  if (lifetime <= 0 || lifetime > config.maxTokenLifetimeSeconds) {
    throw new AuthorizationError('ERR_TOKEN_LIFETIME_EXCESSIVE');
  }

  return toGrant(payload as Record<string, unknown>);
}

Note the expectedIssuer argument. Resolve it from your own routing context — the path segment or the subscription the request targets — and never from the token’s own iss claim. Looking up the configuration by a value the token supplies means an attacker who controls any trusted issuer’s tokens picks their own validation rules.

Engineering Note: The maxTokenLifetimeSeconds check catches an issuer misconfiguration that no signature check can. A partner who quietly moves from five-minute to thirty-day tokens has not broken your verification, but they have made every captured delivery replayable for a month.

Step 3: Map scopes to an authorization decision in the handler

Authentication produced a WebhookGrant. The authorization decision compares that grant against the concrete event being delivered, and it produces one of exactly two outcomes with a reason attached. Keep it a pure function so it is trivially testable and so no database call can accidentally run before the decision.

Handler authorization decision Three sequential checks on audience, tenant match, and event scope, each with the specific 403 reason returned when it fails. Authorization decision, evaluated before any business logic aud matches this endpoint? token tenant = payload tenant? scope covers the event type? Process event in tenant context yes yes yes no no no 403 wrong audience 403 tenant mismatch 403 scope not granted Each denial carries its own reason code, so a scope gap never looks like a key problem.
Three checks, three distinct denial reasons: the split is what makes an over-restrictive subscription distinguishable from an attack in your dashboards.
export type Decision =
  | { readonly allowed: true; readonly tenantId: string; readonly subscriptionId: string }
  | { readonly allowed: false; readonly reason: string };

/** "payment.*" grants "payment.captured"; "payment" alone grants nothing below it. */
export function scopeCovers(scope: string, eventType: string): boolean {
  if (scope === eventType) return true;
  if (!scope.endsWith('.*')) return false;
  const prefix = scope.slice(0, -1); // keep the trailing dot
  return eventType.startsWith(prefix) && eventType.length > prefix.length;
}

export function authorize(
  grant: WebhookGrant,
  event: { readonly type: string; readonly tenantId: string },
  endpointAudience: string,
): Decision {
  if (grant.audience !== endpointAudience) {
    return { allowed: false, reason: 'ERR_AUDIENCE_MISMATCH' };
  }
  if (grant.tenantId !== event.tenantId) {
    return { allowed: false, reason: 'ERR_TENANT_MISMATCH' };
  }
  if (!grant.scopes.some((scope) => scopeCovers(scope, event.type))) {
    return { allowed: false, reason: 'ERR_SCOPE_NOT_GRANTED' };
  }
  return { allowed: true, tenantId: grant.tenantId, subscriptionId: grant.subscriptionId };
}

Wire it into the route so the decision precedes every side effect, and so downstream code receives the tenant from the token, never from the body:

import express from 'express';

const app = express();
const ENDPOINT_AUDIENCE = 'https://api.corp.example/hooks/acme';

app.post('/hooks/acme', express.json({ limit: '1mb' }), async (req, res) => {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'ERR_MISSING_TOKEN' });
  }

  let grant: WebhookGrant;
  try {
    grant = await authenticate(header.slice(7), 'https://events.acme.io');
  } catch (err) {
    return res.status(401).json({ error: (err as Error).message });
  }

  const event = req.body as { type: string; tenantId: string; id: string };
  const decision = authorize(grant, { type: event.type, tenantId: event.tenantId }, ENDPOINT_AUDIENCE);

  if (!decision.allowed) {
    req.log?.warn({ evt: 'webhook_denied', reason: decision.reason, jti: grant.tokenId });
    return res.status(403).json({ error: decision.reason });
  }

  // Downstream code takes the tenant from the DECISION, not from the body.
  await handleEvent({ ...event, tenantId: decision.tenantId });
  res.status(202).json({ accepted: true });
});

Step 4: Issue least-privilege tokens per subscription

Everything above is only as tight as the tokens being minted. If the issuer stamps the sender’s full entitlement set into every token, the scope claim degrades into decoration. Derive the claims from the subscription record at mint time, and give the token a lifetime measured in minutes so a captured one expires before it is useful.

Least-privilege token issuance The delivery worker obtains a token minted from the subscription record's tenant and event grants, then presents it to the receiving endpoint. Subscription store Token issuer Delivery worker Your endpoint read tenant and grants JWT: aud, tid, scp, 5 min POST event, Bearer token 202 accepted, or 403 denied One scope set per subscription minted fresh for each delivery batch
The issuer, not the delivery worker, decides what a token may do — the worker never sees an entitlement broader than the subscription it is delivering for.
import { SignJWT, importPKCS8 } from 'jose';
import { randomUUID } from 'node:crypto';

interface SubscriptionRecord {
  readonly id: string;
  readonly tenantId: string;
  readonly eventGrants: readonly string[]; // e.g. ['payment.*', 'refund.created']
  readonly endpointAudience: string;
}

const privateKey = await importPKCS8(process.env.WEBHOOK_SIGNING_KEY_PEM!, 'ES256');
const ACTIVE_KID = process.env.WEBHOOK_SIGNING_KID!;

export async function mintDeliveryToken(sub: SubscriptionRecord): Promise<string> {
  if (sub.eventGrants.length === 0) {
    throw new Error('Refusing to mint a token with an empty scope set');
  }

  const now = Math.floor(Date.now() / 1000);
  return new SignJWT({
    tid: sub.tenantId,
    scp: sub.eventGrants.join(' '),
  })
    .setProtectedHeader({ alg: 'ES256', kid: ACTIVE_KID, typ: 'JWT' })
    .setIssuer('https://events.acme.io')
    .setAudience(sub.endpointAudience) // the subscriber's endpoint, not a wildcard
    .setSubject(sub.id)
    .setJti(randomUUID())
    .setIssuedAt(now)
    .setExpirationTime(now + 300)
    .sign(privateKey);
}

Engineering Note: Refusing to mint on an empty grant list matters more than it looks. A subscription record that loses its event grants through a bad migration would otherwise produce tokens with scp: "", and a permissive scope matcher elsewhere might read that as “no restrictions”. Fail at issuance, where one alert fires, rather than at ten thousand handlers.

Verification and testing

The decision function is pure, so the important tests are cheap and belong in unit scope. The integration tests exist to prove the wiring order — that no side effect can precede the decision.

import { authorize, scopeCovers } from './authorize';

const grant = {
  issuer: 'https://events.acme.io',
  audience: 'https://api.corp.example/hooks/acme',
  subscriptionId: 'sub_9f21c4',
  tenantId: 'tenant_eu_314',
  scopes: ['payment.*', 'refund.created'],
  tokenId: 'jti-1',
} as const;

const AUD = 'https://api.corp.example/hooks/acme';

test('wildcard scope stops at the dot boundary', () => {
  expect(scopeCovers('payment.*', 'payment.captured')).toBe(true);
  expect(scopeCovers('payment.*', 'payments.captured')).toBe(false);
  expect(scopeCovers('payment.*', 'payment')).toBe(false);
});

test('denies a cross-tenant delivery', () => {
  const d = authorize(grant, { type: 'payment.captured', tenantId: 'tenant_us_9' }, AUD);
  expect(d).toEqual({ allowed: false, reason: 'ERR_TENANT_MISMATCH' });
});

test('denies an ungranted event type', () => {
  const d = authorize(grant, { type: 'account.deleted', tenantId: 'tenant_eu_314' }, AUD);
  expect(d).toEqual({ allowed: false, reason: 'ERR_SCOPE_NOT_GRANTED' });
});

In production, emit webhook_denied with the reason and the sub claim as labels and alert on any sustained non-zero rate for ERR_TENANT_MISMATCH. Unlike a scope gap, which usually means a subscription needs widening, a tenant mismatch has no benign explanation and deserves paging rather than a ticket. A curl probe makes the split visible during a rollout:

# Same token, two endpoints: the second must be refused by the audience check.
TOKEN=$(node -e "require('./mint').mintDeliveryToken(SUB).then(t=>console.log(t))")
for URL in https://api.corp.example/hooks/acme https://api.corp.example/hooks/other; do
  curl -sS -o /dev/null -w "$URL -> %{http_code}\n" \
    -H "Authorization: Bearer $TOKEN" \
    -H 'Content-Type: application/json' \
    -d '{"id":"evt_1","type":"payment.captured","tenantId":"tenant_eu_314"}' \
    "$URL"
done
# Expect: 202 then 403

Failure modes and gotchas

Frequently Asked Questions

The tenant check needs the event body, so does authorization happen after parsing untrusted JSON?

It does, and that is acceptable once authentication has already succeeded and the parser runs under a hard size limit. The property that matters is that no side effect reaches a database, a queue, or an outbound call before the decision, not that parsing is deferred. Treat the parsed body purely as a value to compare against, and take everything you act on from the decision object instead.

What if the provider's tokens carry no tenant claim at all?

Then the endpoint has to supply the tenant itself: give each subscription its own path and audience, store the tenant on the subscription record, and resolve it from the audience the token was minted for. That is strictly weaker than a signed claim, so pair it with a comparison against the tenant named in the payload and refuse any mismatch. Ask the provider for a signed tenant claim as well, since most will add one when a customer asks.

Should a refused delivery return 403 or a 2xx to stop the sender retrying?

Return 403. A tenant or scope refusal is a genuine failure, and hiding it behind a success code destroys the only signal the provider has that its integration is misconfigured. Most senders treat 4xx as permanent and stop retrying anyway, and those that keep retrying hand you a metric worth alerting on rather than a misconfiguration nobody ever notices.

How quickly does widening a subscription's grants take effect?

Within one token lifetime, because grants are stamped in at mint time and tokens live for minutes. Update the subscription record and the next token carries the new set, with no revocation list to propagate and no cache to invalidate. The same mechanism is why short lifetimes matter when narrowing a grant: a token minted before the change keeps its old scope until it expires.

If every subscription already has its own endpoint URL, are scope claims still needed?

Yes. A dedicated URL pins the audience, which stops a token being replayed against a different endpoint, but it says nothing about which event types that subscription may deliver. Without a scope check, an integration registered only for payment events can post an account deletion to its own endpoint and be accepted, because the audience matched perfectly.

Can the tenant claim be trusted when the issuer is a third party?

The claim proves the issuer asserted that value and nothing more, so it should never be used directly as a primary key in your own storage. Map the issuer's identifier to yours through the subscription record and treat an unmapped value as a denial. That indirection means a partner who renumbers their tenants, or slips during their own provisioning, cannot address a tenant you never connected them to.