Skip to content

Search docs

Jump between documentation pages.

Browse docs

Protect a DaloyJS API with AWS Cognito

Amazon Cognito user pools provide hosted sign-up, sign-in, MFA, and federation. This guide verifies the access tokens Cognito issues using the AWS-recommended aws-jwt-verify library, pure TypeScript, zero runtime dependencies, and edge-runtime compatible via Web Crypto.

Cognito access-token verification
Client appCognito (user pool)DaloyJS APICognito JWKS
  1. 01asyncClient appCognito (user pool)Sign in via hosted UI / authorization-code flowCognito issues access + ID tokens (RS256)
  2. 02requestClient appDaloyJS APICall API with Authorization: Bearer <access token>
  3. 03asyncDaloyJS APICognito JWKSCognitoJwtVerifier fetches signing keys (cached)hydrate() pre-warms the JWKS cache
  4. 04noteDaloyJS APIDaloyJS APIVerify signature, tokenUse, clientId, scope & cognito:groups
  5. 05responseDaloyJS APIClient appReturn protected data after the scope or group guard passes
The DaloyJS API only verifies the access token Cognito issued. It never sees user passwords, and the JWKS is cached so most requests verify without a network round-trip.

1. Provision

  1. Create a user pool in the AWS console, then add an app client. Note the User pool ID (e.g. us-east-1_AbCdEfGhI) and the App client ID.
  2. Configure a resource server with custom scopes (e.g. my-api/read, my-api/write) and authorize them on the app client.
  3. Enable a hosted UI domain or use the OAuth 2.0 authorization-code flow from your client app. Your DaloyJS API only needs to verify the resulting access token, it never sees passwords.

2. Install

ts
pnpm add aws-jwt-verify

3. Environment variables

ts
# .env
COGNITO_USER_POOL_ID=us-east-1_AbCdEfGhI
COGNITO_CLIENT_ID=1example23456789

4. Verify tokens

bearerAuth() extracts the token safely and hands it to the SDK. Returning a user object puts it on ctx.state.user, which is what the built-in scope and role guards read.

ts
// src/auth.ts
import { CognitoJwtVerifier } from "aws-jwt-verify";
import { bearerAuth } from "@daloyjs/core";

const verifier = CognitoJwtVerifier.create({
  userPoolId: process.env.COGNITO_USER_POOL_ID!,
  tokenUse: "access", // or "id"
  clientId: process.env.COGNITO_CLIENT_ID!,
});

// Pre-fetch the JWKS so the first request isn't slowed by a cold cache.
void verifier.hydrate();

export const cognito = bearerAuth({
  realm: "my-api",
  validate: async (token) => {
    try {
      const claims = await verifier.verify(token);
      return { sub: claims.sub, scopes: claims.scope?.split(" ") ?? [], claims };
    } catch {
      return false; // 403
    }
  },
});

declare module "@daloyjs/core" {
  interface AppState {
    user?: { sub?: string; scopes: string[]; claims: Record<string, unknown> };
  }
}

verifier.hydrate() downloads the JWKS up front so the first authenticated request doesn't pay a network round-trip. Subsequent key rotations are picked up automatically.

5. Guard a route

ts
import { z } from "zod";
import { App, rateLimit, requireRoles, requireScopes, secureHeaders } from "@daloyjs/core";
import { cognito } from "./auth.ts";

const app = new App();
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 100 }));
app.use(cognito);

app.get(
  "/me",
  {
    hooks: requireScopes(["my-api/read"], { phase: "preBody" }),
    responses: {
      200: { description: "OK", body: z.object({ sub: z.string() }) },
    },
  },
  ({ state }) => ({ status: 200, body: { sub: String(state.user?.sub) } }),
);

// Cognito groups arrive in `cognito:groups`.
app.get(
  "/admin/stats",
  {
    hooks: requireRoles(["admins"], { claim: "cognito:groups" }),
    responses: { 200: { description: "OK" } },
  },
  () => ({ status: 200, body: {} }),
);

Without the SDK: jwk()

The built-in jwk() verifies Cognito tokens too. The catch: Cognito access tokens have no aud claim, they carry client_id instead. So turn off the audience check and do Cognito's two checks yourself in verify (a false answers 403):

ts
import { jwk } from "@daloyjs/core";

const region = "us-east-1";
const issuer = `https://cognito-idp.${region}.amazonaws.com/${process.env.COGNITO_USER_POOL_ID}`;

export const cognito = jwk({
  jwks: `${issuer}/.well-known/jwks.json`,
  algorithms: ["RS256"],
  issuer,
  allowAnyAudience: true, // access tokens carry client_id, not aud
  verify: (claims) =>
    claims.token_use === "access" && claims.client_id === process.env.COGNITO_CLIENT_ID,
});

Do not skip that verify. Without it, an ID token, or an access token issued to a different app client of the same pool, would be accepted.

Trusting multiple pools or IdPs

CognitoJwtVerifier.create([...]) accepts an array of pool configurations to trust JWTs from more than one user pool. To trust a Cognito pool and a non-Cognito OIDC IdP, use the generic JwtVerifier with validateCognitoJwtFields in a customJwtCheck.

Notes on tokens

  • Access tokens carry scope (space-separated string) and cognito:groups: use them for API authorization.
  • ID tokens carry user attributes (email, name) and an aud claim. Verify them with tokenUse: "id" when your UI needs profile data.
  • Cognito signs with RS256. The library refuses alg: none and symmetric algorithms by design.

See also Entra ID, Auth0, and the auth integrations overview.

Edit this page on GitHub

Last updated