Nested JWTs in Node.js: Decrypting a Token Is Not Verifying It
I have been building a compliance-ready backend kit: a NestJS and TypeScript monorepo where every capability maps to a named control in HIPAA, PCI-DSS v4.0.1, or the SOC 2 Trust Services Criteria. This post is the first in a series pulling the interesting parts out of it, one control at a time.
Start with the access token, because it is where I made the most mistakes.
A normal signed JWT is tamper-evident, but its payload is plaintext base64url. Anyone holding the token can read it: the browser it was issued to, the end user, a browser extension, your log aggregator, your error tracker, any proxy in between. Mine carry a tenant id, a user id, and the complete list of roles and permissions the principal holds. That is a useful map for anyone deciding what to attack.
RFC 9068 names this threat directly in section 6: it “becomes possible for clients and potentially even end users to directly peek inside the token claims collection of unencrypted tokens.”
So encrypt the token. That is the easy conclusion, and it opens a trapdoor that signed-only tokens do not have.
The format
An access token in the kit is a nested JWT: signed first, then the whole signed token is encrypted.
inner JWS alg: ES256 typ: crbk-at+jwt kid: <signing key id>
outer JWE alg: A256KW enc: A256GCM
cty: JWT typ: crbk-at+jwt kid: <encryption key id>
The claims inside are small and tenant-scoped:
{
"sub": "<user id within the tenant>",
"tid": "<tenant id>",
"roles": ["tenant-admin"],
"permissions": ["users:read", "users:write", "roles:manage"],
"iss": "...", "aud": "...", "iat": 0, "exp": 0
}
If JWS, JWE and JWKS are not yet familiar, I wrote a developer’s guide to those primitives and a walkthrough of signing versus encryption earlier. This post assumes them and goes at the part those posts do not cover: what goes wrong when you combine the two.
Sign, then encrypt. Not the other way around
RFC 7519 section 11.2 is unambiguous: “normally producers should sign the message and then encrypt the result (thus encrypting the signature). This prevents attacks in which the signature is stripped, leaving just an encrypted message.”
Encrypt-then-sign leaves the signature on the outside, where anyone can remove it and re-sign. Sign-then-encrypt puts the signature inside the ciphertext, so stripping it means decrypting first.
Two lines with jose, and the order is the whole point:
const jws = await new SignJWT(claims)
.setProtectedHeader({ alg: "ES256", typ: TOKEN_TYP, kid: material.signing.kid })
.setIssuer(policy.issuer)
.setAudience(policy.audience)
.setIssuedAt()
.setExpirationTime(`${policy.ttlSeconds}s`)
.sign(material.signing.key);
return new CompactEncrypt(new TextEncoder().encode(jws))
.setProtectedHeader({
alg: "A256KW",
enc: "A256GCM",
cty: "JWT",
typ: TOKEN_TYP,
kid: material.encryption.kid,
})
.encrypt(material.encryption.key);
Note what is not copied onto the outer header: no iss, sub, aud, exp, iat or tid. Replicating a claim outside the ciphertext hands back exactly the information the encryption exists to withhold. kid is safe because it names a key rather than a subject, and the JWKS publishes those names anyway.
The trapdoor: decrypting is not verifying
Here is the mistake this whole post exists to prevent.
You have a five-segment JWE. You call compactDecrypt. It succeeds. You now hold a claims set. It is extremely tempting to treat that as authenticated, because decryption felt like a cryptographic check.
It was not. RFC 8725 (BCP 225) section 2.3 covers exactly this as incorrect composition of encryption and signature. A successful decryption tells you one thing: the sender had the encryption key. It tells you nothing whatsoever about the claims inside.
The attack is direct. The outer layer here is symmetric, an A256KW shared secret. Anyone holding it can encrypt a claims set of their own choosing: "roles": ["tenant-admin"], any tid they like, any sub. If nothing ever checks the inner signature, that token is accepted. You built an encrypted envelope and then trusted its contents because the envelope opened.
So verification has to do both layers, in order:
// 1. Decrypt the outer JWE with the key named by the outer kid.
// 2. Check the outer cty is "JWT".
// 3. VERIFY THE INNER JWS SIGNATURE with the key named by the inner kid.
// 4. Check typ, iss, aud, exp, iat with the configured clock tolerance.
RFC 8725 section 3.3 requires it: “the entire JWT MUST be rejected if any of them fail to validate … also for Nested JWTs in which both outer and inner operations MUST be validated.”
Step 3 is the one that gets dropped, because by then you already have a payload in your hands and the code appears to work. Every test passes. The tokens your own service issues verify perfectly. The hole only shows up when someone else has the encryption key, which in a multi-service deployment is the normal case.
In the kit, four smoke assertions guard this, including one that presents a valid JWE wrapping a forged inner JWS and requires a 401. That is the test worth writing, because it is the only one that fails if step 3 goes missing.
cty: JWT is required, and jose will not do it for you
RFC 7519 section 5.2: for a nested JWT the outer cty “MUST be present; in this case, the value MUST be JWT, to indicate that a Nested JWT is carried in this JWT.”
jose neither sets this on encryption nor checks it on decryption. Both sides are therefore explicit in my code. It is a small thing that tells a recipient the plaintext is itself a JWT and must be processed again, and it costs one line to get right.
The rule I did not expect: key resolvers must be synchronous
This is the part I would most want a reviewer to notice.
jose lets you pass a function to resolve a key from the token header. The obvious implementation looks up the kid in your database, or asks KMS to unwrap it. Both are async. Both are wrong.
jose’s own documentation for these callbacks warns that “no token components have been verified at the time of this function call.” The kid is attacker-controlled input, read from an unverified header before anything has been checked. RFC 8725 section 2.9 covers relying on unverified header parameters generally.
If resolving a kid can await, then an unauthenticated request carrying an invented kid drives a database query or a KMS unwrap. That is a remote amplification primitive: cheap for the attacker, expensive for you, and reachable without credentials.
So the resolvers are typed synchronous, which makes the async version unrepresentable rather than merely discouraged:
export type SigningKeyResolver = (kid: string) => CryptoKey | undefined;
export type EncryptionKeyResolver = (kid: string) => Uint8Array | undefined;
A synchronous resolver can only ever consult memory, so a forged kid costs a map lookup. The key registry keeps an in-memory snapshot; the refresh happens elsewhere, between a failed verification and its single retry, so the resolvers stay pure.
Two resolvers, two types, on purpose
Look at those return types again: CryptoKey for signing, Uint8Array for encryption.
They are separately typed so that a signature verification structurally cannot be handed the symmetric encryption key. Not guarded against, not asserted at runtime, simply not representable. One resolver taking a purpose argument would leave that substitution one typo away.
On top of that, both the header alg and the stored algorithm are checked against an allow-list, because RFC 8725 section 3.1 requires each key be used with exactly one algorithm and that this be enforced at the point of use.
Algorithms are also allow-listed at decryption rather than read from the token’s own header, so a token cannot nominate something weaker than what you intended to require:
const decrypted = await compactDecrypt(token, resolveEncryptionKey, {
keyManagementAlgorithms: ["A256KW"],
contentEncryptionAlgorithms: ["A256GCM"],
// RFC 8725 section 3.6: never accept a compressed JWE.
// 0 rejects outright rather than exposing a decompression bomb.
maxDecompressedLength: 0,
});
An absent kid is refused rather than falling back to “the active key”. A fallback would let a token minted under a revoked key be accepted simply by omitting the header.
Four smaller choices, each with a reason
ES256 inside, not HS256. A symmetric inner signature means anything that can verify can also mint. Hand a second service the verification key and you hand it the power to forge roles for any tenant. With ES256 the private key signs and the public half verifies, so verification distributes without distributing the ability to issue. RFC 7518 section 3.1 rates ES256 “Recommended+”, above RS256’s “Recommended”.
A256KW, not dir. Under dir the long-lived shared key is the content encryption key, so RFC 7518 section 8.4’s limit on AES-GCM invocations under a single key binds that one key directly and forever. A256KW mints a fresh random content encryption key per token and wraps it, and section 8.4 says the consideration “does not apply to the composite AES-CBC HMAC SHA-2 or AES Key Wrap algorithms”.
typ is crbk-at+jwt, not at+jwt. RFC 9068 section 2.1 makes at+jwt an assertion of conformance to the OAuth 2.0 JWT access token profile. The kit does not conform: section 2.2 requires a client_id claim it does not issue, and section 2.1 requires RS256 among the supported algorithms, which it does not support. Stamping at+jwt would be a false statement in a machine-readable field.
No decode-without-verify helper. Verification returns the claims and both protected headers, and the headers are obtainable no other way. Operators do need to see which key verified a token. But a decodeToken helper that skipped verification is the section 2.3 mistake wearing a helpful name, and it eventually gets called somewhere that matters. Making the headers a product of successful verification means there is no way to read them off a token that did not pass.
The practical blocker: your JWT library probably cannot do this
If you are on NestJS, this is where the plan meets the dependency tree.
@nestjs/jwt wraps jsonwebtoken, which is JWS-only and cannot produce a JWE at all. And passport-jwt reads the token through a synchronous extractor, so it cannot await a decryption even if you bolt one on.
So @nestjs/jwt, @nestjs/passport, passport and passport-jwt all came out. What replaced them is a plain CanActivate guard calling jose, which drops four dependencies for one that has none.
The guard does something else worth copying: it requires claims.tid === request.tenant.id, in the same step as authentication rather than as a separate guard. A separate guard can be left out of a chain. A token for tenant A presented with x-tenant-id: B must be rejected. Database-per-tenant routing means no data actually crosses, but the caller would be acting inside a tenant they hold no account in while carrying A’s permissions. It fails closed: no resolved tenant throws rather than passing.
And every failure raises the same error whatever the cause. Telling a caller which check failed tells an attacker which part of a forged token to fix next.
One more small thing: a repeated Authorization header arrives as an array, and the guard refuses rather than picking one. Which header a proxy forwards is not something to leave to chance.
What this does not buy you
Encryption on an access token is narrower than it feels, and the honest list matters more than the feature.
- It is not a substitute for TLS. RFC 8725 section 3.2 says that if a JWT is protected end-to-end by a transport layer using current algorithms, “there may be no need to apply another layer of cryptographic protections to the JWT”. The value here is defence against everything that legitimately handles the token after TLS terminates: logs, extensions, error trackers, the user.
- It does not stop theft or replay. The result is still a bearer credential. RFC 9700 (BCP 240) section 2.2.1 points at sender-constraining, meaning mTLS (RFC 8705) or DPoP (RFC 9449), and the kit implements neither. If replay is your concern, I wrote about fingerprint validation and replay defences separately.
- It does not hide the size of the claim set. RFC 8725 section 2.4 notes encryption leaks plaintext length, so token length still reveals roughly how many permissions a principal holds. Compressing to disguise that is forbidden by section 3.6, which is why
maxDecompressedLengthis 0. - Permissions are baked in at login. A permission change takes effect when the token expires, up to 900 seconds later by default. There is no revocation list.
The part worth taking with you
If you take one thing from this: a successful decryption is not an authentication event. It proves possession of a key, and nothing about the claims that came out. In a nested JWT the signature check is the authentication, and it is the step that silently goes missing because everything appears to work without it.
Write the test that wraps a forged inner JWS in a valid outer JWE and demands a 401. If that test does not exist, you do not know which of the two layers your service is actually trusting.
The token code is in packages/crypto/src/tokens.ts in the kit repo, heavily commented with the RFC citations behind each decision. Next in this series: the append-only, hash-chained audit log, and why the advisory lock has to be taken before the chain head is read.