Blog

All posts
John Damask · 2026-03-26
engineeringarchitecture

About this post

This post contains the complete planning document I made with Claude when introducing user accounts to Now I Get It! It shows that the job of the engineer remains the same when it comes to design, problem solving, and balancing trade-offs. The only things that have changed are that the engineer no longer writes the code and a good generalist can build amazing things when paired with AI specialists. Basically, the role of an engineer has leveled up.

Plan: Add User Accounts with Cognito Auth (#24)

Context

NowIGetIt is currently a public research tool — anyone can upload a PDF, and all completed explanations appear in the public gallery. Issue #24 transforms it into a user-first commercial application with individual accounts, personal galleries, and ownership-gated access.

A prior attempt (PR #37) validated the architecture but the branch went 213 commits stale and was closed. The design decisions from that PR inform this plan. The codebase now has 20 Lambdas, 10 DynamoDB tables, admin 2FA, feedback, takedowns, content screening, and a blog system — all of which need to coexist with user auth.

Target environment: Test account first, NOT prod.

Key Design Decisions

  1. Custom branded auth UI with Cognito SDK — use @aws-sdk/client-cognito-identity-provider directly in the browser for sign-up, sign-in, password reset. No Cognito hosted/managed login. No Amplify. Full control over the UX.
  2. SRP (Secure Remote Password)USER_SRP_AUTH flow via InitiateAuth. Password never sent over the wire, even to Cognito. The SDK handles the SRP math.
  3. Refresh token in httpOnly cookie — a lightweight Lambda (via API Gateway) stores the refresh token in an httpOnly, Secure, SameSite=Strict cookie scoped to the auth API path. Access/ID tokens live only in JS memory. On page refresh, the browser silently sends the cookie to a refresh endpoint to get fresh tokens. No tokens in localStorage.
  4. JWT authorizer on API Gateway — zero-Lambda auth overhead for protected routes. Validates the ID token at the API Gateway layer before Lambda runs.
  5. Cognito User Pool Domain still needed — only for social login OAuth redirects (Google, Apple, etc.) where the user is redirected to Cognito's /oauth2/authorize with the provider specified. Users never see a Cognito-branded page.
  6. Admin auth stays separate — admin uses a dedicated token-based auth mechanism with MFA, NOT Cognito. The two auth systems coexist independently.
  7. is_public defaults to false — new jobs are private by default. Users explicitly choose to share: either via direct URL or by listing in the public gallery. This is the right default for a commercial user-first app.
  8. Legacy jobs are orphaned — existing jobs (no user_id) appear in public gallery, never in any user's My Gallery. No data backfill required at launch.
  9. Status endpoint stays public — job_id is a UUID (unguessable); anyone with the URL can poll status. Needed for shared links.
  10. Feedback stays public — anyone viewing a generated page can give feedback, regardless of ownership.
  11. Cognito handles auth emails — verification codes, password reset codes come from Cognito's built-in email service. Postmark remains for app emails (waitlist, takedowns, support).
  12. Social login is architecturally supported but not wired up in v1 — the UserPool domain and OAuth config are in place; adding Google/Apple IdPs is a follow-up config change, no code changes needed.
  13. Per-path CSP hardening for generated pages — add a second CloudFront cache behavior for pages/* with a restrictive CSP (no connect-src to API except feedback, no unsafe-inline). This limits what XSS on generated pages can do, complementing the httpOnly cookie protection.
  14. Two-tier sharing model — generated pages are private by default. Users can share via: (a) "anyone with the URL" — sets a shared_via_link flag, the page remains accessible at /pages/{job_id}.html but doesn't appear in the gallery; (b) "public gallery" — sets is_public: true, page appears in the public gallery. Both require explicit user action. Unshared pages are still accessible by direct URL (job_id is an unguessable UUID) but don't appear anywhere public.

Token Architecture

Browser                           API Gateway + Lambda@Edge/Lambda
───────                           ─────────────────────────────────

Sign In (SRP):
  InitiateAuth ──────────────────► Cognito (direct SDK call from browser)
  ◄─── AccessToken, IdToken, RefreshToken

Store tokens:
  AccessToken  → JS memory (variable)
  IdToken      → JS memory (variable)
  RefreshToken → POST /api/auth/session ──► Lambda sets httpOnly cookie
                                            (HttpOnly; Secure; SameSite=Strict; scoped to auth path)

API calls:
  fetch(/api/upload, { Authorization: Bearer <IdToken> })
  ──► JWT Authorizer validates IdToken ──► Lambda runs with claims

Page refresh (tokens lost from memory):
  GET /api/auth/refresh (cookie sent automatically)
  ──► Lambda reads cookie, calls Cognito InitiateAuth(REFRESH_TOKEN_AUTH)
  ◄─── New AccessToken + IdToken in response body
  Store in JS memory again

Sign Out:
  POST /api/auth/logout
  ──► Lambda calls GlobalSignOut (revokes refresh token server-side)
  ──► Clears httpOnly cookie (Max-Age=0)
  Clear JS memory tokens

This means:

Recommended Approach

Six-phase implementation: Infrastructure (CFN) → Auth Backend (token Lambda) → App Backend (existing Lambda changes) → Frontend Auth UI → Deploy Scripts → Local Dev.

Changes

Phase 1: CloudFormation Infrastructure (aws/nowigetit.yaml)

1.1 Cognito User Pool + Client + Domain

1.2 UsersTable (DynamoDB)

1.3 User-Date GSI on JobsTable

1.4 JWT Authorizer

JwtAuthorizer:
  Type: AWS::ApiGatewayV2::Authorizer
  Properties:
    ApiId: !Ref HttpApi
    AuthorizerType: JWT
    IdentitySource: $request.header.Authorization
    Name: CognitoJwtAuthorizer
    JwtConfiguration:
      Audience:
        - !Ref CognitoUserPoolClient
      Issuer: !Sub 'https://cognito-idp.${Region}.amazonaws.com/${CognitoUserPool}'

1.5 Auth Token Lambda + Routes

New Lambda for managing refresh token cookies:

1.6 Route Authorization

Routes fall into three authorization tiers:

JWT-protected (user must be authenticated):

Public (no authorizer):

Admin-gated (unchanged, separate auth):

1.7 New App Lambda Functions (CFN resources)

1.8 CORS Update

CORS complexity note: HTTP API Gateway's built-in CORS only supports a single configuration per API. Since /api/auth/* routes need AllowCredentials: true but other routes don't, we have two options:

Recommendation: (A) — global AllowCredentials: true with the existing specific origin is safe and simplest.

1.9 CSP Update

In SecurityHeadersPolicy, add the Cognito service endpoint to connect-src (needed for SDK API calls from the browser).

1.10 IAM Update

Add to LambdaRole policy:

1.11 Stack Outputs

Add: UserPoolId, UserPoolClientId, UserPoolDomain, UsersTableName

Phase 2: Auth Backend — Token Session Lambda

2.1 New: backend/lambda_auth_session.py

Handles all /api/auth/* routes. Single Lambda, route-dispatched:

POST /api/auth/session — Store refresh token as cookie

GET /api/auth/refresh — Exchange cookie for fresh tokens

POST /api/auth/logout — Revoke and clear

Rate limiting: Apply IP-based rate limiting to all auth endpoints to prevent brute force.

Phase 3: App Backend — Lambda Changes

3.1 New: backend/user_auth.py

Shared utility module (mirrors admin_auth.py pattern):

def get_user_from_event(event):
    """Extract user identity from JWT authorizer claims.
    Returns {'user_id': sub, 'email': email} or None."""
    claims = (event.get("requestContext", {})
              .get("authorizer", {})
              .get("jwt", {})
              .get("claims", {}))
    sub = claims.get("sub")
    if not sub:
        return None
    return {"user_id": sub, "email": claims.get("email", "")}

3.2 New: backend/lambda_post_confirmation.py

Cognito post-confirmation trigger:

3.3 New: backend/lambda_my_gallery.py

3.4 New: backend/lambda_user_profile.py

3.5 New: backend/lambda_job_visibility.py

3.6 Modify: backend/lambda_upload.py

3.7 Modify: backend/lambda_confirm.py

3.8 Modify: backend/lambda_gallery.py

3.9 Modify: backend/lambda_feedback.py

Phase 4: Frontend — Custom Auth UI

4.1 New: backend/static/auth.js

Core auth module using @aws-sdk/client-cognito-identity-provider (loaded from CDN: https://cdn.jsdelivr.net/npm/@aws-sdk/client-cognito-identity-provider/dist-es/index.js or bundled as an IIFE).

SDK loading strategy: Since the project has no build step (vanilla HTML/JS), we need the SDK available in the browser. Options:

Recommendation: (B) — pre-bundle into cognito-sdk.js. One-time build, no runtime CDN dependency, works with the existing no-build-step pattern. The bundle is ~50-80 KB gzipped.

Build step (one-time, committed to repo):

npm init -y
npm install @aws-sdk/client-cognito-identity-provider
npx esbuild --bundle --minify --format=iife --global-name=CognitoSDK \
  --outfile=backend/static/cognito-sdk.js \
  node_modules/@aws-sdk/client-cognito-identity-provider/dist-es/index.js

auth.js exports to window.NigiAuth:

Token lifecycle:

  1. signIn() → tokens in memory + refresh token in httpOnly cookie
  2. API calls → authFetch() sends ID token from memory
  3. Token expires (1 hour) → authFetch() detects expiry, calls /api/auth/refresh, cookie yields new tokens
  4. Page refresh → tryRestore() calls /api/auth/refresh, cookie yields new tokens
  5. Sign out → POST /api/auth/logout revokes refresh token + clears cookie + clears memory

4.2 New: backend/static/auth-ui.js

Custom branded auth UI components. Renders sign-up, sign-in, password reset forms in a modal or inline container. Matches the NowIGetIt dark theme (uses existing CSS variables).

UI states:

Integration pattern: auth-ui.js creates DOM elements programmatically (same pattern as footer.js and feedback-widget.js). No HTML templates — everything is JS-generated for portability across pages.

4.3 Modify: backend/static/footer.js

4.4 Modify: backend/static/index.html

4.5 New: backend/static/my-gallery.html

4.6 New: backend/static/profile.html

4.7 Modify: backend/static/gallery.html

Phase 5: Deploy Script Updates

5.1 Modify: scripts/deploy-common.sh

5.2 Modify: environment deploy scripts

Each environment's deploy script passes its own CognitoDomainPrefix (e.g., {app-name}-test, {app-name}) to CloudFormation.

Phase 6: Local Development (backend/main.py)

6.1 Mock auth endpoints

Add routes that simulate the auth session Lambda:

For local dev, auth.js talks to the same API_BASE (localhost), so cookie-based refresh works the same way. The Cognito SDK calls (SignUp, InitiateAuth, etc.) go directly to AWS Cognito — they work from localhost since they're HTTP calls to Cognito's service endpoint, not to our backend.

6.2 Mock JWT middleware

Add FastAPI middleware that checks Authorization: Bearer {token}:

6.3 New routes in main.py

6.4 Modify existing routes

Critical Files

| File | Action | Purpose | |------|--------|---------| | aws/nowigetit.yaml | Modify | Cognito, UsersTable, UserDateIndex GSI, JWT authorizer, auth routes, CORS, CSP, IAM, outputs | | backend/lambda_auth_session.py | Create | Refresh token cookie management (session/refresh/logout) | | backend/user_auth.py | Create | Shared JWT claim extraction utility | | backend/lambda_post_confirmation.py | Create | Cognito trigger: create user record | | backend/lambda_my_gallery.py | Create | Authenticated personal gallery | | backend/lambda_user_profile.py | Create | Profile CRUD + account cancellation | | backend/lambda_job_visibility.py | Create | Toggle job public/private | | backend/lambda_upload.py | Modify | Add user_id and is_public to job records | | backend/lambda_confirm.py | Modify | Add ownership check | | backend/lambda_gallery.py | Modify | Filter by is_public | | backend/lambda_feedback.py | Modify | Optionally store user_id | | backend/main.py | Modify | Mock auth, auth routes, new routes, filter changes | | backend/static/cognito-sdk.js | Create | Pre-bundled AWS Cognito SDK (~50-80 KB gzipped) | | backend/static/auth.js | Create | Frontend auth module (SRP, token management, authFetch) | | backend/static/auth-ui.js | Create | Custom branded sign-in/sign-up/reset UI components | | backend/static/index.html | Modify | Auth integration, sign-in gate, authFetch | | backend/static/footer.js | Modify | Auth-aware links (My Gallery, Profile, Sign In/Out) | | backend/static/gallery.html | Modify | Rename heading, add My Gallery link | | backend/static/my-gallery.html | Create | Personal gallery with visibility toggles | | backend/static/profile.html | Create | User profile + account management | | scripts/deploy-common.sh | Modify | Config.js extension, new files, new outputs | | deploy-test.sh | Modify | Cognito domain prefix parameter | | deploy-prod.sh | Modify | Cognito domain prefix parameter |

Dependencies & Ordering

Phase 1 (CFN) ──────────────────────────────────────────────────────►
  1.1 Cognito ─► 1.4 JWT Authorizer ─► 1.6 Route auth
  1.2 UsersTable ─► 1.7 New Lambda CFN resources
  1.3 UserDateIndex GSI
  1.5 Auth session Lambda + routes
  1.8 CORS  (independent)
  1.9 CSP   (independent)
  1.10 IAM  (must include UsersTable + cognito-idp)
  1.11 Outputs (depends on all above)

Phase 2 (Auth Backend) ─────────────────────────────────────────────►
  2.1 lambda_auth_session.py (depends on Phase 1 Cognito resources)

Phase 3 (App Backend) ──────────────────────────────────────────────►
  3.1 user_auth.py ─► 3.2-3.9 (all Lambdas import it)
  3.2 post_confirmation (independent)
  3.3-3.5 new Lambdas (can parallelize)
  3.6-3.9 Lambda modifications (can parallelize)

Phase 4 (Frontend) ─────────────────────────────────────────────────►
  4.1 cognito-sdk.js (one-time build, independent)
  4.2 auth.js ─► 4.3-4.7 (all pages depend on auth.js)
  4.3 auth-ui.js (depends on auth.js)
  4.4 index.html (depends on auth.js + auth-ui.js)
  4.5 my-gallery.html (depends on auth.js)
  4.6 profile.html (depends on auth.js)
  4.7 gallery.html, footer.js (depends on auth.js)

Phase 5 (Deploy) ───────────────────────────────────────────────────►
  5.1 deploy-common.sh ─► 5.2 deploy-test.sh ─► (deploy to test)

Phase 6 (Local Dev) ────────────────────────────────────────────────►
  6.1-6.4 (can happen in parallel with Phases 2-4)

Risks & Open Questions

  1. Cognito SDK bundle size: The @aws-sdk/client-cognito-identity-provider needs to be pre-bundled for browser use. Estimated ~50-80 KB gzipped. This is a one-time build step that produces a committed artifact. If the bundle is too large, we could use the Cognito REST API directly with manual SRP implementation (more code, smaller bundle).

  2. SRP complexity: The SDK handles SRP math internally, but the USER_SRP_AUTH flow involves a challenge-response (InitiateAuthPASSWORD_VERIFIER challenge → RespondToAuthChallenge). The SDK's InitiateAuth with USER_SRP_AUTH handles this transparently, but error handling needs to cover all challenge types.

  3. Cookie domain and path: The refresh token cookie is scoped to the auth API path so it's only sent on auth endpoints. SameSite=Strict prevents CSRF. Secure requires HTTPS (works in prod, need to handle localhost for dev — localhost cookies can be Secure in modern browsers via http://localhost).

  4. API Gateway CORS with credentials: Enabling AllowCredentials: true globally is safe because AllowOrigins is already a specific domain. But it means all CORS preflight responses include Access-Control-Allow-Credentials: true, which is fine but worth noting.

  5. Cognito email sending limits: Cognito's default email sending uses a shared Amazon SES sandbox. For test, this is fine. For prod with real users, we may need to configure a custom email sender (SES with verified domain) or use Cognito's advanced security features. This is a prod-readiness concern, not a blocker for test.

  6. GSI backfill time: Adding UserDateIndex to a live JobsTable takes time. Not a downtime risk but a deploy-time delay.

  7. Social login (future): The Cognito domain and OAuth config are in place. Adding Google/Apple requires: (a) create identity providers in Cognito, (b) add them to SupportedIdentityProviders, (c) add social login buttons to auth-ui.js that redirect to /oauth2/authorize?identity_provider=Google. No backend code changes.

Verification

Automated

Manual (test account)