Skip to content
Back to All Posts

Sign In Once, Verify Anywhere—How Cognito Tokens Work

Published: 12/10/2026

The fundamental rule is this: identity comes from a verified token, never from what a client or a model says. Cognito proves who the user is once; every service after that checks the signed token for itself; and the sub claim it recovers is the only thing that decides whose data a request can touch.

This post sticks to the foundations: what Cognito issues and how a service checks it. Part 4 then follows the token through Strobe Assistant itself.

"Auth" is really three jobs

I realised that I often say "auth" for three separate jobs. Keeping them apart makes the whole architecture much easier to follow:

QuestionNameWho answers itIn Strobe
Who are you?AuthenticationCognito, once, with a passwordA1 auth Lambda → Cognito InitiateAuth
Is this token genuine and current?VerificationEvery service, on its own, with public keysAPI Gateway JWT authorizer, auth.ts, FastMCP JWTVerifier
What may this user touch?AuthorizationThe app's own code, using the verified subfilter={"userId": sub}, post.userId == sub

Cognito only does the first job. It knows nothing about posts, photos or vectors, so authorization is always the app's own job.

How Cognito tokens work: a client signs in once to the Cognito user pool with email and password and receives three signed tokens (ID, access and refresh); any service that later receives the ID token verifies it locally with the pool's cached public keys (kid lookup, RS256 signature, iss, aud, exp and token_use) without calling Cognito, then trusts sub as the user's identity; below, authentication, verification and authorization are shown as three separate questions

What Cognito hands back after sign-in

A user pool is a user directory plus a token issuer. When a sign-in succeeds, it returns three tokens:

  • ID token: who the user is (sub, email, cognito:username) and which app client it was minted for (aud).
  • Access token: what the app may call (scope). It carries client_id instead of aud.
  • Refresh token: a long-lived token (30 days by default) that is traded for new ID and access tokens. It isn't a JWT you can verify: it's encrypted, only Cognito can read it, and Cognito can revoke it.

ID and access tokens are JWTs (JSON Web Tokens): three base64url-encoded parts joined by dots, header.payload.signature.

Why Strobe uses the ID token, not the access token. Strobe Assistant's verifiers (the agent's auth.ts and the MCP server's JWTVerifier) check aud against the app client id. Cognito access tokens have no aud claim, so that check rejects them by design. The ID token also carries the user details the agent uses for its display name. Both files explain this in their docstrings, and I cover why both of them verify the token in Part 3.

How a service trusts a token without asking Cognito

Cognito signs every token with a private key that never leaves Cognito, and publishes the matching public keys at a well-known URL as a JWKS (JSON Web Key Set):

https://cognito-idp.ap-southeast-2.amazonaws.com/<poolId>/.well-known/jwks.json

With those keys, a service verifies a token locally, with no call back to Cognito per request:

  1. Read kid from the header and pick the matching public key from the JWKS (downloaded once, then cached).
  2. Check the RS256 signature. If even one byte of the header or payload has changed, it fails.
  3. iss must be my pool's URL. This rejects tokens from other pools, such as a classmate's.
  4. aud must be my app client id. This rejects tokens minted for a different app.
  5. exp must be in the future.
  6. token_use must be "id" (aws-jwt-verify, used by the agent, also checks this).

Every expected value (the pool URL, the client id, the keys) is known in advance, which is why no per-request call to Cognito is needed. Once all the checks pass, the claims can be treated as facts, and sub is safe to use as the user's identity.

Anatomy of a Strobe ID token: the encoded header, payload and signature, the decoded header (kid, alg RS256) and illustrative payload claims (sub, iss, aud, token_use, iat, exp, email), and what the verifier checks for each one (kid, iss, aud, token_use, exp, signature), with notes that base64url is not encryption, that the token never goes in a URL, and a comparison of ID and access tokens

Note that the signature proves the token is authentic and unedited; it does nothing to hide what's inside. This design has two consequences worth remembering:

  • Readable ≠ trustworthy. base64url is an encoding, not encryption, so anyone holding the token can read the payload. Secrets must never go in the claims. The Strobe Assistant WebUI decodes the token itself just to show your name (tokenLabel() in main.ts), without verifying anything. That's fine for display, but never for decisions.
  • Bearer = whoever holds it. A valid token works for anyone who has it until exp. So it must travel only over TLS, never sit in a URL (where it ends up in logs and browser history), and stay short-lived. And because nobody asks Cognito per request, a service can't see a sign-out either. Even revoking a refresh token doesn't reach it: AWS notes that revoked tokens still pass any library that checks only the signature and expiry. That's why ID and access tokens are short-lived (Cognito's default is 60 minutes) and the refresh token is the revocable part.

User identity vs workload identity

There are two kinds of identity in this system, and they shouldn't be confused:

  • User identity: the Cognito JWT. It answers whose data is this request for?
  • Workload identity: an IAM role. It answers which component is calling AWS? The agent's and MCP server's ECS task roles and the Lambda execution roles sign every AWS API call (SigV4).

In Strobe, the user's token never unlocks AWS. Bedrock, S3 Vectors and DynamoDB are reached with IAM roles; the token only decides which rows those calls ask for.

Where Cognito sits in the Strobe app

In Assessment 1 (the photo-sharing app that Strobe Assistant extends; see the series intro), Cognito sits behind a thin auth Lambda, and API Gateway's JWT authorizer guards every protected route.

Where Cognito sits in Strobe (A1): sign-in goes from the React client through API Gateway to the auth Lambda, which calls Cognito InitiateAuth with a secret hash and returns only the ID token; on protected routes the API Gateway JWT authorizer verifies the token before any Lambda runs (401 otherwise), the upload Lambda presigns a key prefixed with the verified sub, and the post Lambda allows an update only when post.userId equals sub (403 otherwise)

Sign-in goes through a Lambda, not straight from the browser to Cognito. The app client is confidential: it has a client secret, and every InitiateAuth call needs a secret hash computed from it. A browser can't keep a secret, so POST /v1/auth/login (a public route) invokes the auth Lambda, which calls Cognito server-side and returns { user, token }, where token is the ID token.

Protected routes are checked before any code runs. The HTTP API's JWT authorizer verifies the signature, iss, audience (aud, or client_id for access tokens) and exp against the pool. A bad token gets a 401 and the Lambda is never invoked, so you don't pay for the invocation and no code path can forget the check. The Lambda receives the verified claims in its event. Below is the shape for an HTTP API event. It's illustrative, because the A1 Lambdas aren't in the A2 repo:

claims = event["requestContext"]["authorizer"]["jwt"]["claims"]
user_id = claims["sub"]   # never read the user id from the request body

sub becomes rules in two places:

  • Ownership checks. For example, editing a post requires post.userId == sub; otherwise it's a 403.
  • Storage keys. The upload Lambda presigns a PUT for the key {sub}/{postId}/{fileId}. The client never chooses the {sub} part. This matters later, because the A2 ingestion worker trusts that prefix (see Part 4).

Cognito is also where the moderator group lives. Group membership appears in the ID token as cognito:groups.

Further reading

You May Also Like