Mock JWT Test Data: Covering the Whole RBAC Matrix
Authorization bugs don't hide in the happy path. They hide in the claims
matrix: the viewer who can somehow PATCH, the expired token that still
gets a 200 because the handler only checked the signature, the editor
from another tenant who can read your records because a query forgot its
WHERE tenant_id. Covering that matrix means one token per combination,
and hand-writing mock JWT test data — a fresh claims payload pasted into
each test, with timestamps that go stale the week after you write them —
stops scaling at about the fourth case. The fix is to stop writing token
payloads one at a time: template the claims once, generate every cell of
the matrix from it, and sign them in your test code.
What gets generated and what gets signed
Be precise about the split, because it's the part people get wrong. JsonFabrica generates the claims payload — a JSON object. It has no signing function, no HMAC, no RSA, no JWT support of any kind. It cannot hand you a token string — and it shouldn't: a signed token is only valid against a key your API trusts, so signing belongs where that key lives — in your test setup.
The pipeline looks like this:
JsonFabrica → claims JSON → your test signs it (jose / jsonwebtoken,
test-only key) → eyJhbGciOi... →
Authorization: Bearer <token>
That split is a feature, not a limitation. The signing key belongs in your test setup, where it's a test-only secret that verifies against a test-only configuration of your API — not in a data generation service. What you actually need generated is the tedious part: the twelve different claim payloads, each internally consistent, each with the right scopes for its role.
A claims template for mock JWT test data
Start from the payload your API's authorization middleware actually
reads. Anything that varies per test case becomes a getParam call;
anything constant stays literal. Save this as one template with
POST /v1/templates, keep the templateId the API returns (the tests
below read it from JWT_CLAIMS_TEMPLATE_ID), and every case in the
matrix renders from it:
{
"sub": "<createSeq('jwtSub', 'uuid')><getSeq('jwtSub')>",
"email": "<getRandomEmail('example.test')>",
"iss": "<getParam('iss', 'https://auth.test.local/')>",
"aud": "<getParam('aud', 'api.test.local')>",
"tenant_id": "<getParam('tenant')>",
"roles": <getParam('roles')>,
"scopes": <getParam('scopes')>,
"iat": <getParam('iat')>,
"exp": <getParam('exp')>
}
Two details worth noting. roles, scopes, iat, and exp have no
quotes around the placeholder — a placeholder outside a JSON string
emits its value as native JSON, so an array param comes out as an array
and a number param comes out as a number, not as "[\"admin\"]". And
getParam('tenant') has no default, so generation fails loudly if a test
case forgets to say which tenant its user belongs to; getParam('iss', '...') does have one, because a sane default issuer is fine for the
majority of cases and only the negative tests need to override it. That's
the same parameterized-template pattern covered in
one template, every scenario
— applied here to the claims object instead of a business entity.
createSeq('jwtSub', 'uuid') gives every generated subject a distinct
identifier, so two users in the same run are never accidentally the same
principal. See the function reference for
the sequence types; uuid and string both work for a sub.
Don't generate exp and iat inside the template
JWT timestamps are NumericDate values — epoch seconds. JsonFabrica
has no now() function and no epoch function: getRandomDate(from, to)
returns an ISO-8601 string, and
formatDate only understands
YYYY/MM/DD/HH/mm/ss tokens. Neither one produces epoch seconds, so
don't try to force it.
You have two honest options:
getRandomNumber(min, max)over an explicit epoch-seconds range you pick yourself — fine when the exact instant doesn't matter and you just need a plausibleiat.- Compute the timestamps in the test and pass them in as params — the right choice for anything that depends on valid-vs-expired, because "expired" only means anything relative to the clock the API is checking against right now.
For an RBAC matrix, always use the second one:
const now = Math.floor(Date.now() / 1000);
const VALID = { iat: now - 60, exp: now + 3600 };
const EXPIRED = { iat: now - 7200, exp: now - 300 };
A token whose exp was baked into a fixture file last March isn't
testing your expiry logic — it's testing that your fixture is old. Anchor
it to the test's own clock and the expired case stays expired forever,
and the valid case never goes stale in CI.
Generating test data for role-based access control from params
Now the matrix. Three roles × valid/expired × own-tenant/other-tenant is
twelve cells, and every cell is the same template with a different
params object. That's exactly what the per-template generate endpoint
takes — POST /v1/templates/{templateId}/generate with a body of
{ seed, params } (see the
templates API reference) — so one cell
is one call. The body for the admin-valid-own cell, posted to
/v1/templates/{templateId}/generate with your template's ID:
{
"seed": 20260924,
"params": {
"roles": ["admin"],
"scopes": ["reports:read", "reports:write", "users:admin"],
"tenant": "tenant-acme",
"iat": 1790000000,
"exp": 1790003600
}
}
The response is { data, meta }, where data is the generated claims
object and meta echoes the seed and templateId. The
viewer-expired-other cell is the same request with
roles: ["viewer"], scopes: ["reports:read"],
tenant: "tenant-globex", and an exp in the past.
Note that the role and scope arrays are written out literally per
cell. There's no "pick three random scopes" function —
getRandomElement returns exactly
one element from the values you give it, not a random subset — and you
wouldn't want one anyway. A permission set that changes shape between
runs turns a deterministic authorization assertion into a coin flip. Say
which scopes an editor has, once, and generate every editor with them.
In practice you build the twelve cells in a loop rather than typing them, and fetch each one's claims:
const SCOPES = {
admin: ["reports:read", "reports:write", "users:admin"],
editor: ["reports:read", "reports:write"],
viewer: ["reports:read"],
};
const now = Math.floor(Date.now() / 1000);
const cells = [];
for (const role of ["admin", "editor", "viewer"]) {
for (const validity of ["valid", "expired"]) {
for (const tenant of ["own", "other"]) {
cells.push({
role,
validity,
tenant,
alias: `${role}-${validity}-${tenant}`,
params: {
roles: [role],
scopes: SCOPES[role],
tenant: tenant === "own" ? "tenant-acme" : "tenant-globex",
iat: validity === "valid" ? now - 60 : now - 7200,
exp: validity === "valid" ? now + 3600 : now - 300,
},
});
}
}
}
const claimsByCell = {};
for (const [i, cell] of cells.entries()) {
const res = await fetch(
`https://api.jsonfabrica.com/v1/templates/${process.env.JWT_CLAIMS_TEMPLATE_ID}/generate`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.JSONFABRICA_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({ seed: 20260924 + i, params: cell.params }),
},
);
if (!res.ok) throw new Error(`${cell.alias}: HTTP ${res.status}`);
const { data } = await res.json();
claimsByCell[cell.alias] = data; // the claims object for that cell
}
Twelve small requests in a global setup hook, once per suite — not per
test. The res.ok check matters more than it looks: if a cell's
params is missing tenant, getParam('tenant') fails generation and
you want the suite to stop there, not sign an error body.
Each cell gets its own fixed seed (20260924 + i). That pins the random
fields, so each cell's email is the same on every run and a failure is
reproducible from the same inputs. It also keeps the cells apart:
getParam and getSeq don't draw from the random generator, so with one
shared seed every cell would get the identical email, and any
email-keyed lookup in your API would treat twelve "different" users as
one. The sub is different: getSeq reads a durable sequence
counter that keeps advancing across runs rather than restarting from the
seed, which is what you want for a subject identifier — no two runs ever
reuse the same principal. Seeding is covered in more depth in
deterministic test data.
Signing each payload and asserting allow vs. deny
The generator's job is done; the rest is ordinary test code. With jose
and an HS256 test secret:
import { SignJWT } from "jose";
const secret = new TextEncoder().encode(process.env.TEST_JWT_SECRET);
const sign = (claims) =>
new SignJWT(claims)
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.sign(secret);
Make that test secret at least 32 random bytes. RFC 7518 requires a key
of at least 256 bits for HS256. jose won't stop you from using a
shorter one, and neither will many verifiers, so a short secret means
your tests don't match what production should be enforcing.
SignJWT signs the payload as given, so the iat and exp that came
back in the claims document are the ones that end up in the token — which
is exactly why they were computed relative to the test clock. If your API
verifies RS256 against a JWKS endpoint, swap the secret for a test-only
key pair from jose's generateKeyPair and point the API's issuer
config at a local JWKS in test setup; the generation half doesn't change.
Then drive the matrix. Write the expected outcome as a function, not as twelve hand-maintained numbers — the function is your authorization spec, and disagreeing with it is what a failure should mean:
// PATCH on a report owned by tenant-acme
function expectedStatus({ role, validity, tenant }) {
if (validity === "expired") return 401; // bad token beats any role
if (tenant === "other") return 404; // don't leak existence
return role === "viewer" ? 403 : 200; // viewer is read-only
}
for (const cell of cells) {
test(cell.alias, async () => {
const claims = claimsByCell[cell.alias];
const token = await sign(claims);
const res = await fetch(`${API}/reports/rpt_acme_42`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify({ title: "edited" }),
});
expect(res.status).toBe(cell.expected ?? expectedStatus(cell));
});
}
Twelve real requests, twelve real tokens, one template. Adding a fourth
role is one entry in the loop and one line in SCOPES. Adding a new
claim — an org_id your middleware just started reading — is one edit to
the template, and all twelve cells get it at once, instead of twelve
fixture files drifting apart until one of them silently stops exercising
anything.
Fake auth token payloads for the nasty edge cases
Once the matrix is parameterized, the nastier auth cases cost one entry
in cells each, because they're just fake auth token payloads with
different params — spread a base admin cell and override one field:
const base = cells.find((c) => c.alias === "admin-valid-own");
cells.push({
...base,
alias: "admin-wrong-aud",
expected: 401,
params: { ...base.params, aud: "api.other-service.local" },
});
Push these into cells in global setup before the fetch loop runs —
otherwise the loop never generates them and
claimsByCell["admin-wrong-aud"] is undefined when the test signs it.
And give each one an explicit expected: the spread copies role,
validity, and tenant from the base admin cell, so expectedStatus
would return 200 for it. The test's cell.expected ?? expectedStatus(cell)
picks up the override. The first four below should expect 401 or 403,
never 200. Good candidates:
- Wrong audience:
{ aud: "api.other-service.local" }with an otherwise perfect admin token. A surprising number of services verify the signature and skipaud, which means a token minted for a different service is accepted. - Wrong issuer:
{ iss: "https://evil.test/" }, same idea. - No roles at all:
{ roles: [] }— an authenticated user with no grants should be a clean403, not a crash onroles[0]. - Role/scope mismatch:
{ roles: ["viewer"], scopes: ["reports:write"] }, for APIs that check both. Whichever one loses should lose consistently. - Multiple roles:
{ roles: ["editor", "viewer"] }, to pin down whether your resolver unions permissions or takes the first match. Itsexpecteddepends on your spec:200for a PATCH if roles union (editor can write),403if the most restrictive role wins. Decide which one is correct and write that decision down as theexpectedvalue.
None of these need a new template or a new fixture file. They're rows in the same array, which is the whole reason to template the claims payload rather than write tokens by hand.
FAQ
How do I generate mock JWT test data for RBAC tests?
Template the claims payload — sub, roles, scopes, tenant, iss,
aud, iat, exp — and leave the values that vary per test case as
getParam calls. Then loop over the matrix cells (each role, each
validity state, each tenant) and call
POST /v1/templates/{templateId}/generate once per cell with that cell's
params object. Your test code signs each generated payload into a real
token before sending it, so the generator only ever deals with JSON.
Can JsonFabrica generate a signed JWT token?
No. JsonFabrica has no signing, HMAC, RSA, or JWT function of any kind —
it generates the JSON claims payload only. Sign that payload in your own
test code with a real JWT library such as jose or jsonwebtoken, using
a test-only key or secret that exists nowhere near production, and send
the resulting string as a Bearer token.
How do I create an expired JWT for testing?
Compute the timestamps in your test rather than in the template. Take
Math.floor(Date.now() / 1000) and subtract an offset for an expired
case or add one for a valid case, then pass both iat and exp in the
generate request's params. JsonFabrica has no now() or epoch-timestamp
function, so anchoring exp to the test's own clock is both the accurate
and the deterministic option.
How do I test multi-tenant access control with JWT claims?
Add the tenant identifier to the claims template as a parameter and generate at least two variants of every role: one whose tenant claim matches the fixture being accessed, and one pointing at a different tenant. The cross-tenant cases are the ones that catch a handler that checks the role but forgets to scope the query, which is one of the most common authorization bugs in a multi-tenant API.
Do I need a test case for every role and scope combination?
Not every combination, but every dimension your authorization code actually branches on. Roles, token validity, and tenant match are usually enough for a core matrix — three roles by valid/expired by own/other tenant is twelve cases. Generating them from one template means adding a fourth role or a new claim costs one array entry, not twelve new fixture files.
Templated claims payloads with per-request params are available through
the JsonFabrica API today — you bring the signing key and the
assertions. The function reference covers
getParam and the sequence helpers used in the template above.
Generate realistic test data with JsonFabrica
Describe the shape of your data once, then generate as many fresh, realistic JSON documents as you need via a simple API call.