Context: Personal auth lab tying a React SPA to Amazon Cognito User Pools + API Gateway/Lambda. No production users, just a sandbox.
AI assist: ChatGPT/Copilot scaffolded the initial hooks + Lambda verifier. Prompt logs live in notes/ai-prompts.md.
Status: Flow works today (hosted UI → code exchange → JWT verification). AuthZ granularity, CDK/Terraform, and mobile testing are still TODOs.

Reality snapshot

  • Frontend: React + TypeScript, hosted UI redirect, token exchange, context for session state, HttpOnly refresh cookie.
  • Backend: API Gateway + Lambda verifying tokens via aws-jwt-verify, storing user data in DynamoDB keyed by sub.
  • Limitations: Lab-only AWS account, no custom domains, refresh endpoint limited to browser usage, CI only runs unit tests so far.

Flow overview

  1. User clicks “Login” → redirected to Cognito hosted UI (/oauth2/authorize).
  2. After login/MFA, Cognito redirects back with ?code=....
  3. React exchanges the code for ID + access + refresh tokens (/oauth2/token).
  4. Access token stored in memory (React context). Refresh token stored in an HttpOnly cookie so JS can’t read it.
  5. API calls include Authorization: Bearer <access>. Lambda verifies the token, checks scopes, and processes the request.
  6. When the access token expires, the app hits /auth/refresh (Lambda) to trade the refresh token for a new pair.

React hook (trimmed)

export function useAuth() {
const [session, setSession] = useState<Session | null>(null);
useEffect(() => {
const code = new URL(window.location.href).searchParams.get("code");
if (!code) return;
(async () => {
const tokens = await exchangeCodeForTokens(code);
setSession(tokens);
window.history.replaceState({}, "", "/");
})();
}, []);
const authenticatedFetch = useCallback(
async (input: RequestInfo, init?: RequestInit) => {
if (!session) throw new Error("Not authenticated");
return fetchWithAuth(session.accessToken, input, init);
},
[session]
);
return { session, authenticatedFetch };
}

Lambda verifier

const verifier = CognitoJwtVerifier.create({
userPoolId: process.env.USER_POOL_ID!,
tokenUse: "access",
clientId: process.env.CLIENT_ID!,
});
export const handler = async (event) => {
try {
const token = event.headers.authorization?.split(" ")[1];
const payload = await verifier.verify(token);
return { statusCode: 200, body: JSON.stringify({ userId: payload.sub }) };
} catch {
return { statusCode: 401, body: JSON.stringify({ error: "Unauthorized" }) };
}
};

Security habits

  • Access tokens stay in memory; refresh tokens live in HttpOnly cookies. No localStorage usage.
  • API Gateway authorizers enforce scopes per route; Lambda double-checks claims.
  • CloudWatch logs capture token verification failures + request IDs for debugging.
  • GitHub Actions uses OIDC to assume an IAM role; no long-lived AWS keys in the repo.

Developer experience

  • Local testing: AWS SAM for Lambda + API Gateway mocks, seeded Cognito users for the hosted UI.
  • Integration tests: Playwright automates login → API call → logout on every main build (headless mode).
  • CI/CD: GitHub Actions builds the SPA, runs Playwright, deploys to S3/CloudFront, and publishes Lambda via SAM.

What still needs work

  • Finer-grained scopes (per feature) and group-based authorization.
  • WAF + CloudFront logging so I can spot brute-force attempts.
  • Mobile viewport testing for the hosted UI + React app.
  • IaC cleanup: migrate from SAM CLI commands to CDK or Terraform with clearer state management.

Links

References

Footnotes

  1. AWS Documentation, “Amazon Cognito User Pools,” https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html

  2. AWS Documentation, “Using JSON Web Tokens with Amazon Cognito,” https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html

  3. AWS Labs, “aws-jwt-verify,” https://github.com/awslabs/aws-jwt-verify