Node.js Serverless on AWS Lambda · HTTP APIs on Lambda
Authentication: JWT Authorizers and Claims
Why decoding a JWT without verifying it is an open door, and how to authenticate Lambda APIs properly.
Scenario
Example app: a B2B API authenticates users with an identity provider (Amazon Cognito or Auth0). To "keep it simple", the handler decodes the bearer token to read the user's tenant and role.
What you see in production
A penetration tester gets admin access to another tenant's data by editing the token payload in a browser, base64-encoding it and sending it unsigned. Expired tokens also keep working for days.
The naive code
export const handler = async (event) => {
const token = event.headers.authorization.replace("Bearer ", "");
const claims = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()); // decoded, NOT verified
if (claims.role !== "admin") return json(403, { error: "forbidden" });
return json(200, await listInvoices(claims.tenantId));
};
Reproduce it
The simulation forges a token and shows which checks catch it:
const SECRET = "issuer-signing-key";
const sign = (payload) => { const p = JSON.stringify(payload); return { payload: p, sig: `${SECRET}:${p.length}:${p}` }; };
const verifySig = (t) => t.sig === `${SECRET}:${t.payload.length}:${t.payload}`; // stands in for RS256 verification
const now = 1_800_000_000;
const real = sign({ sub: "u-1", tenantId: "acme", role: "user", aud: "invoices-api", exp: now + 3600 });
const forged = { payload: JSON.stringify({ sub: "u-1", tenantId: "globex", role: "admin", aud: "invoices-api", exp: now + 3600 }), sig: real.sig };
const expired = sign({ sub: "u-2", tenantId: "acme", role: "user", aud: "invoices-api", exp: now - 60 });
function naive(t) { const c = JSON.parse(t.payload); return `allowed as ${c.role}@${c.tenantId}`; }
function verified(t) {
if (!verifySig(t)) return "rejected: bad signature";
const c = JSON.parse(t.payload);
if (c.exp <= now) return "rejected: expired";
if (c.aud !== "invoices-api") return "rejected: wrong audience";
return `allowed as ${c.role}@${c.tenantId}`;
}
for (const [name, t] of Object.entries({ real, forged, expired })) {
console.log(name.padEnd(8), "| naive:", naive(t).padEnd(24), "| verified:", verified(t));
}
Output
real | naive: allowed as user@acme | verified: allowed as user@acme forged | naive: allowed as admin@globex | verified: rejected: bad signature expired | naive: allowed as user@acme | verified: rejected: expired
Root cause
A JWT's payload is only base64url-encoded; anyone can read and rewrite it. Its security comes from the signature, checked against the issuer's public key, plus validation of exp, iss and aud. Decoding without verification trusts attacker-controlled input for identity and authorization.
The fix
Let API Gateway verify tokens before the function runs: an HTTP API JWT authorizer (or a Cognito authorizer on REST APIs) validates the signature against the issuer's JWKS, the expiry, issuer and audience, and passes verified claims to the function. Then authorize in the handler using those claims, scoping every query to the caller's tenant:
export const handler = async (event) => {
const claims = event.requestContext.authorizer.jwt.claims; // already verified by API Gateway
if (!hasScope(claims, "invoices:read")) return json(403, { error: "forbidden" });
return json(200, await listInvoices({ tenantId: claims["custom:tenantId"] }));
};
If you must verify in code (for example behind a function URL), use a maintained library such as aws-jwt-verify or jose with the issuer's JWKS, and cache the keys.
Trade-offs
- Gateway authorizers reject bad tokens before you pay for function time and keep auth consistent; Lambda authorizers allow custom logic (API keys, opaque tokens) at the cost of an extra invocation (cache their results).
- Authentication (who you are) at the gateway does not replace authorization (what you may access) in the function.
- Short token lifetimes limit damage from leaks but require refresh flows.
Detect it in production
- Security tests that send unsigned, expired and wrong-audience tokens in CI.
- Logs of 401/403 rates per client and tenant; spikes may indicate probing.
- Code review rule: no
JSON.parse(atob(...))or base64 decoding of tokens outside a verification library.
Key points
- Decoding a JWT is not verifying it; check signature, expiry, issuer and audience.
- Use API Gateway JWT or Cognito authorizers, or a vetted library.
- Authorize in the handler and scope data to the caller's tenant.
Further reading: Controlling access to HTTP APIs with JWT authorizers · aws-jwt-verify on GitHub
Practice questions
Why is decoding a JWT payload in the handler not enough for authentication?
Answer
The payload is only base64url-encoded, so anyone can change it. Trust comes from verifying the signature against the issuer's public keys and validating expiry, issuer and audience; without that, attackers can forge identities and roles.
What does an HTTP API JWT authorizer check, and what must the function still do?
Answer
It validates the token's signature using the issuer's JWKS, plus expiry, issuer and audience (and optionally scopes) before invoking the function. The function must still perform authorization: check permissions and scope data access to the caller's tenant or user.
When would you use a Lambda authorizer instead of a JWT authorizer?
Answer
When tokens are not standard JWTs from an OIDC issuer (opaque tokens, API keys with custom lookup) or you need custom logic. Cache authorizer results to avoid an extra invocation per request.
Open in the app to mark lessons complete, take notes and track your progress.