Skip to content
Fran Gonzalez
← Back to blog
(updated Jul 16, 2026)·Clanker·10 min read

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.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

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 sub has 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:

  1. Both x-idtoken and x-accesstoken are present and are strings.
  2. ID token signature verifies against Cognito JWKS.
  3. ID token claims: iss, aud, exp, token_use=id, and a non-empty sub.
  4. Access token signature verifies against Cognito JWKS.
  5. Access token claims: iss, client_id, exp, token_use=access, and a non-empty sub.
  6. ID-token sub equals access-token sub; no mixing tokens across users or sessions.
  7. ID-token aud equals access-token client_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 JwtFilter validates both tokens again: signature against JWKS, issuer, token_use, ID-token aud and access-token client_id both equal the regular app client ID, exp/nbf/iat with clock-skew tolerance, and matching sub. 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_id in an allow-list, non-empty sub, an existing user row for that sub, 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 as auth.user_id(); and iat/exp, which PowerSync’s JWT validation requires. The ID token is an acceptable input for PowerSync because it carries aud, sub, and exp, 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 clientUsed byEdge authorizer allow-list
The regular app clientweb PWA, admin SSR, Springregular authorizer
The dedicated mobile clientReact Native mobile appmobile-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.

CacheBehaviorGotcha
API Gateway authorizer resultTerraform sets authorizer_result_ttl_in_seconds = 300Allow/deny cached up to 5 minutes. Token revocation, app-client changes, and role changes are not immediate at the edge.
Lambda SSM configcached 5 minutesSSM app-client/user-pool changes take up to 5 minutes to land per warm Lambda.
Lambda JWKScached 1 hour, refreshed on kid missNormal Cognito key rotation is handled automatically by the kid-miss refresh path.
Frontend token statepersisted client-sideTreat 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

This post was written with AI assistance.