A Cognito token-pair contract: API Gateway authorizer, Spring, Hono, and PowerSync
How one Cognito user pool serves four client surfaces through a shared token-pair contract, a REQUEST Lambda authorizer, and defense-in-depth re-validation that isolates a new mobile client without duplicating identity.
One AWS Cognito user pool, four client surfaces, two app clients, and a token-pair contract that keeps the new mobile client isolated without duplicating identity or breaking the existing web/admin/backend auth.
Identity constraints
Four surfaces authenticate against one shared Cognito user pool: a web PWA, an admin SSR app, a Spring Boot backend, and a new React Native mobile app. The mobile app needs to evolve its own auth surface (its own onboarding flow and its own app client) without breaking the contract the existing clients already depend on.
Two options fail this constraint:
- A second user pool duplicates identity. User lookup, password reset, and account linking now span two directories, and every downstream service that resolves a user by
subhas to know which pool to ask. Identity stops being a single source of truth. - A single shared app client for every surface gives up isolation. The mobile app cannot rotate its token lifetime, change its callback flow, or get revoked independently of the web/admin clients. One compromised client config touches everyone.
The third option (keep one pool, add a dedicated app client for the mobile surface, and enforce isolation at the authorization layer) is what I shipped.
Token validation design
The token-pair contract (the core)
Protected API Gateway HTTP routes require both Cognito headers on every request:
x-idtoken: <cognito-id-token>
x-accesstoken: <cognito-access-token>
Clients must treat the ID and access tokens as a pair. If either token is missing, the frontend omits both auth headers and treats the session as unauthenticated or refreshable. A partial pair is never sent. No “ID token only because that’s what I have right now.” This single rule removes the partial-token confused-deputy case downstream, because every layer below the edge can assume both tokens are either present and paired, or absent.
The REQUEST Lambda authorizer: 7 checks
A shared REQUEST-type Lambda authorizer sits in front of the protected routes. API Gateway passes the request headers to it; the Lambda reads the user-pool and app-client IDs from SSM Parameter Store, fetches the Cognito JWKS, and validates both tokens. It runs seven checks, in order:
- Both
x-idtokenandx-accesstokenare present and are strings. - ID token signature verifies against Cognito JWKS.
- ID token claims:
iss,aud,exp,token_use=id, and a non-emptysub. - Access token signature verifies against Cognito JWKS.
- Access token claims:
iss,client_id,exp,token_use=access, and a non-emptysub. - ID-token
subequals access-tokensub; no mixing tokens across users or sessions. - ID-token
audequals access-tokenclient_id, and that client is in the authorizer’s configured allow-list.
Condensed handler, implementing all seven checks with jose:
// auth-cognito-validation/index.ts
import { createRemoteJWKSet, jwtVerify } from "jose";
import type { APIGatewayRequestAuthorizerEventV2 } from "aws-lambda";
const cfg = await loadConfigFromSsm(); // { userPoolId, region, allowedClientIds }
const issuer = `https://cognito-idp.${cfg.region}.amazonaws.com/${cfg.userPoolId}`;
const JWKS = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
export const handler = async (event: APIGatewayRequestAuthorizerEventV2) => {
const idToken = event.headers["x-idtoken"];
const accessToken = event.headers["x-accesstoken"];
// 1. both headers present and strings
if (typeof idToken !== "string" || typeof accessToken !== "string") {
return { isAuthorized: false };
}
try {
// 2-3. ID token: signature + iss/aud/exp/token_use/sub
const { payload: id } = await jwtVerify(idToken, JWKS, {
issuer,
audience: cfg.allowedClientIds,
});
if (id.token_use !== "id" || !id.sub) return { isAuthorized: false };
// 4-5. access token: signature + iss/exp/token_use/sub (client_id checked at step 7)
const { payload: access } = await jwtVerify(accessToken, JWKS, {
issuer,
});
if (access.token_use !== "access" || !access.sub) {
return { isAuthorized: false };
}
const clientId = access.client_id as string;
// 6. the pair shares one subject
if (id.sub !== access.sub) return { isAuthorized: false };
// 7. ID aud equals access client_id, and that client is allowed
if (id.aud !== clientId || !cfg.allowedClientIds.includes(clientId)) {
return { isAuthorized: false };
}
return { isAuthorized: true };
} catch {
return { isAuthorized: false };
}
};
The response is a flat decision:
{ "isAuthorized": true }
No rich IAM policy, no path-level resource statements. The authorizer answers one question (“is this a valid, paired token set from an allowed client?”) and lets the application layers answer everything else.
Defense in depth: the edge gate is not the final decision
The API Gateway authorizer is an edge gate, not the final authorization decision. Every downstream layer re-validates the tokens independently:
- Spring Boot’s
JwtFiltervalidates both tokens again: signature against JWKS, issuer,token_use, ID-tokenaudand access-tokenclient_idboth equal the regular app client ID,exp/nbf/iatwith clock-skew tolerance, and matchingsub. It then maps the user to Spring authorities. Gateway allow is never the last word. - The Hono BFF validates the bearer access token on protected business routes: issuer,
token_use=access,client_idin an allow-list, non-emptysub, an existing user row for thatsub, and route-level roles where required. - PowerSync uses the Cognito ID token directly, and this is deliberate, not a mistake. Cognito ID tokens carry
aud=<app-client-id>, which I configure as the PowerSync audience;sub, which PowerSync uses asauth.user_id(); andiat/exp, which PowerSync’s JWT validation requires. The ID token is an acceptable input for PowerSync because it carriesaud,sub, andexp, even though it is not acceptable as a general backend bearer.
The rule: do not use Cognito ID tokens as backend API bearer tokens. ID tokens identify the user for the client; access tokens authorize API and resource access and carry token_use=access. Backend bearer routes take the access token. PowerSync is a special case because it consumes the ID token as an audience-scoped identity assertion, not as a generic API credential.
Condensed re-validation at the application layers:
// JwtFilter.java
@Component
public class JwtFilter extends OncePerRequestFilter {
private final CognitoJwtDecoder idTokenDecoder; // expects aud = app client id
private final CognitoJwtDecoder accessTokenDecoder;
private final String appClientId;
@Override
protected void doFilterInternal(
HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String idToken = req.getHeader("x-idtoken");
String accessToken = req.getHeader("x-accesstoken");
if (idToken == null || accessToken == null) { // pair required, never partial
chain.doFilter(req, res);
return;
}
Jwt id = idTokenDecoder.decode(idToken); // signature + issuer + exp
Jwt access = accessTokenDecoder.decode(accessToken); // signature + issuer + exp
if (!"id".equals(id.getClaim("token_use"))
|| !"access".equals(access.getClaim("token_use"))) {
throw new InvalidBearerTokenException("wrong token_use");
}
if (!appClientId.equals(id.getClaim("aud")) // ID token audience
|| !appClientId.equals(access.getClaim("client_id"))) { // access client_id
throw new InvalidBearerTokenException("client mismatch");
}
if (!Objects.equals(id.getSubject(), access.getSubject())) { // matching sub
throw new InvalidBearerTokenException("sub mismatch");
}
SecurityContextHolder.getContext()
.setAuthentication(toAuthentication(id, access));
chain.doFilter(req, res);
}
}
// src/middleware/auth.ts
import { createMiddleware } from "hono/factory";
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(
new URL(`${env.COGNITO_ISSUER}/.well-known/jwks.json`),
);
const allowedClients = env.COGNITO_ALLOWED_CLIENT_IDS.split(",");
export const requireAccessToken = createMiddleware(async (c, next) => {
const bearer = c.req.header("Authorization")?.replace(/^Bearer\s+/i, "");
if (!bearer) return c.json({ error: "unauthorized" }, 401);
try {
const { payload } = await jwtVerify(bearer, JWKS, {
issuer: env.COGNITO_ISSUER,
});
if (payload.token_use !== "access") {
return c.json({ error: "wrong token_use" }, 401);
}
if (!allowedClients.includes(payload.client_id as string)) {
return c.json({ error: "client not allowed" }, 401);
}
const sub = payload.sub;
if (!sub) return c.json({ error: "missing sub" }, 401);
const user = await c.var.db // onboarded-user row check
.selectFrom("app_users")
.where("cognito_sub", "=", sub)
.selectAll()
.executeTakeFirst();
if (!user) return c.json({ error: "unknown user" }, 401);
c.set("user", user);
await next();
} catch {
return c.json({ error: "invalid token" }, 401);
}
});
Two app clients, one allow-list
The pool now carries two app clients:
| App client | Used by | Edge authorizer allow-list |
|---|---|---|
| The regular app client | web PWA, admin SSR, Spring | regular authorizer |
| The dedicated mobile client | React Native mobile app | mobile-edge authorizer |
The mobile-edge authorizer is the same Lambda artifact as the regular authorizer. The only difference is which SSM parameters it reads: the mobile-edge allow-list contains the dedicated mobile client plus explicitly approved legacy clients. This lets mobile integration routes accept the dedicated mobile client and approved legacy clients without changing the regular Spring routes at all, with no client-id churn on the existing contract.
A single feature flag gates whether legacy clients can reach the mobile integration routes, so the mobile surface can open up gradually instead of going all-at-once.
Caches and revocation gotchas
Every cache below is a place where “I just revoked that token” is not true at the edge.
| Cache | Behavior | Gotcha |
|---|---|---|
| API Gateway authorizer result | Terraform sets authorizer_result_ttl_in_seconds = 300 | Allow/deny cached up to 5 minutes. Token revocation, app-client changes, and role changes are not immediate at the edge. |
| Lambda SSM config | cached 5 minutes | SSM app-client/user-pool changes take up to 5 minutes to land per warm Lambda. |
| Lambda JWKS | cached 1 hour, refreshed on kid miss | Normal Cognito key rotation is handled automatically by the kid-miss refresh path. |
| Frontend token state | persisted client-side | Treat browser/mobile storage as device state, not a secure server session. Never log raw tokens or request headers. |
Verified behavior
- Four client surfaces share one user pool, with real isolation between the new mobile client and the legacy clients; no second pool, no shared client config.
- Seven-point token-pair validation at the edge, before any request reaches Spring or Hono.
- Defense in depth: the edge authorizer, Spring’s
JwtFilter, and the Hono BFF each re-validate independently; PowerSync consumes the ID token with a documented rationale. - Three environments × two authorizer functions (regular + mobile-edge) ship from a single Lambda artifact, differenced only by SSM config.
Remaining tradeoffs
The 300-second authorizer result TTL is a real revocation lag, and I don’t want to pretend it’s free. If a tighter revocation story mattered for this product, I’d trade one of two ways: shorten the TTL and accept the Lambda cost, or keep the long TTL at the edge and push revocation/list checks into Spring and Hono where the decision is fresh. The current setup picks the cheaper option (long TTL, no per-request revocation lookup) because the downstream layers already re-validate signatures and claims. That’s a defensible tradeoff for this workload; it would not be for one where “revoke now” has to mean “now.”
References
- AWS Cognito ID token and access token: why access tokens are for APIs and ID tokens are for the client.
- API Gateway HTTP API Lambda authorizers: the REQUEST authorizer model and the
isAuthorizedresponse shape. - API Gateway authorizer caching (
resultTtlInSeconds): the source of the 300-second revocation lag. - Cognito JWKS and key rotation by
kid: why thekid-miss refresh handles rotation. - PowerSync custom JWT auth: the ID-token-as-audience rationale for using a Cognito ID token directly.
- jose: JWT signature verification used by the authorizer and the Spring filter.
This post was written with AI assistance.