Secure Authentication with JWT, Cognito, and React
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 innotes/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 bysub. - Limitations: Lab-only AWS account, no custom domains, refresh endpoint limited to browser usage, CI only runs unit tests so far.
Flow overview
- User clicks “Login” → redirected to Cognito hosted UI (
/oauth2/authorize). - After login/MFA, Cognito redirects back with
?code=.... - React exchanges the code for ID + access + refresh tokens (
/oauth2/token). - Access token stored in memory (React context). Refresh token stored in an HttpOnly cookie so JS can’t read it.
- API calls include
Authorization: Bearer <access>. Lambda verifies the token, checks scopes, and processes the request. - 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
- Repo (request access): https://github.com/BradleyMatera/cognito-react-auth-lab
- Prompt log + runbooks:
docs/inside the repo - AWS docs used: [Cognito User Pools]1, [JWT Tokens]2, [aws-jwt-verify]3
References
Footnotes
-
AWS Documentation, “Amazon Cognito User Pools,” https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html ↩
-
AWS Documentation, “Using JSON Web Tokens with Amazon Cognito,” https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html ↩
-
AWS Labs, “aws-jwt-verify,” https://github.com/awslabs/aws-jwt-verify ↩