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.
Prerequisites
- Runtime: Node.js 20+ with TypeScript 5+ and
jose5.x (npm i jose). - Asymmetric signing. Use ES256 or RS256 so the issuer holds the private key and you hold only a public verification key. A shared HMAC secret cannot express “this token was minted for subscription X” without you also being able to mint it.
- A working verification pipeline — signature,
kidresolution,exp/nbfwith bounded clock tolerance — from the sibling walkthrough on JWT validation. - A subscription record per integration that already stores the tenant it belongs to and the event types it subscribed to. Scope claims must be derived from this record, never supplied by the caller.
- A tenant identifier that appears in the event payload, so the handler has two independent values to compare rather than trusting one.
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.
| 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.
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.
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.
- Cross-tenant refusal. Mint a token for
tenant_aand deliver an event whose payload namestenant_b; assert403withERR_TENANT_MISMATCHand assert the handler’s database mock recorded zero calls. - Audience replay refusal. Take a token minted for the sandbox endpoint and present it to the production endpoint; assert
403and confirm the signature itself verified, proving the denial came from the authorization gate. - Wildcard boundary.
payment.*must coverpayment.capturedand must not coverpayments.capturedor barepayment. Prefix matching without the trailing-dot guard silently grants the first of those. - Lifetime ceiling. Issue a token with a one-day
expfrom a valid key and assertERR_TOKEN_LIFETIME_EXCESSIVE, proving the ceiling is enforced independently of the signature.
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
- Reading the tenant from the payload instead of the token. This is the whole bug in one line. If the handler does
loadTenant(req.body.tenantId)anywhere before the decision, the token’stidclaim is decorative and any valid caller can address any tenant. Make the decision return the tenant, and make the handler’s signature accept only that value — a type that cannot be constructed from the request body enforces this better than a code review. - Treating an empty or missing scope claim as unrestricted. A
scopes.length === 0check that falls through toallowed: true, or asome()over an empty array that a later refactor inverts, turns a degraded subscription record into a full grant. Reject empty scope sets at parse time intoGrant, before any matching logic can see them. - Accepting an array of audiences.
josewill happily match a token whoseaudis["prod", "sandbox"]against either value. That is correct per the specification and wrong for webhooks: it makes sandbox tokens replayable in production. Assert the claim is a single string and equals this endpoint’s audience exactly, asauthorizedoes above rather than relying only on the library’s check. - Prefix scope matching without a delimiter guard.
eventType.startsWith('payment')grantspayments.deletedfrom a different domain. Always compare against the prefix including its trailing dot, and require the event type to be strictly longer, or the wildcard leaks across namespaces. - Letting long-lived tokens through because the signature is valid. Signature validity and grant duration are independent properties, and only one of them is checked by default. Enforce a maximum lifetime per issuer, and combine it with single-use
jtitracking as in nonce-based replay protection with Redis so a captured token cannot be used twice even inside its window.
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.