Call Analyst — Auth & AWS Plan
Pablo's next-steps, researched + specced. us-east-2 · Cognito · CDK · Stripe-linked · US-only geofence. Dense by request. Companion to your AWS proposal + refactor progress.
welcome-email.mjs. Caught + removed on the refactor branch before any merge; runtime already used process.env. It was the only hardcoded secret in the tree. Key should be rotated (it's in history). Good catch — that would've shipped public.Decisions locked (from your notes + research)
| Area | Decision | Why |
|---|---|---|
| Region | us-east-2 | your call |
| Auth | Cognito User Pools, custom UI via SDK (not Hosted UI) | AWS-native, CDK construct, cheapest at scale, app has bespoke UI |
| IaC | AWS CDK (TS), deploy via AWS CLI/MCP | one control plane |
| MFA | TOTP only (skip SMS initially) | SMS = per-msg cost + SIM-swap/pumping fraud |
| Auth methods | email/password + Google social | Google OAuth client already exists |
| Identity↔billing join | Cognito sub (UUID), stored both ways | deterministic, no extra link table |
| Entitlement store | DynamoDB (PK cognito_sub), NOT a Cognito attr | fast idempotent webhook writes; hot-path read |
| Legal posture | US-only geofence at CDN + TOS + cookie consent at registration | parks GDPR; lightweight, not consent-mgmt |
| SES, CustomEmailSender Lambda (branded) + KMS | full HTML brand control | |
| Geofence | CloudFront Function (viewer-request), US allowlist | redirect not bare 403, path-scoped, ~$0 |
| Login fallback | Clerk if Cognito DX hurts (not Auth0) | best DX, ~$1k@100k; Auth0 = $6k+ trap |
Cost @ 100k MAU
| Service | Plan | $/mo @ 100k | Note |
|---|---|---|---|
| Cognito | Essentials $0.015/MAU, 10k free | ~$1,350 | Plus (adaptive auth) = $2,000, no free tier — skip unless needed |
| SES | $0.10 / 1k emails | ~$10–50 | auto-scales from 50k/day after prod access |
| DynamoDB | on-demand | ~$5–25 | entitlement table, tiny |
| CloudFront Fn | $0.10 / 1M invals | ~$1–10 | geofence |
| Lambda (triggers) | per-invoke | ~$1–5 | pre-signup / post-confirm / email-sender |
Auth RPS @ 100k MAU ≈ 20 RPS peak — well under Cognito's 120 RPS default UserAuthentication quota. No quota increase needed. Users-per-pool soft limit = 40M.
A · Cognito stack (CDK) ~55 lines
import { Stack, Duration, CfnOutput, SecretValue } from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';
const pool = new cognito.UserPool(this, 'CallAnalystPool', {
selfSignUpEnabled: true,
signInAliases: { email: true },
autoVerify: { email: true },
signInCaseSensitive: false,
standardAttributes: { email: { required: true, mutable: true } },
customAttributes: { // the join key + legal record
stripe_customer_id: new cognito.StringAttribute({ mutable: true }),
tos_accepted_at: new cognito.StringAttribute({ mutable: true }),
tos_version: new cognito.StringAttribute({ mutable: true }),
cookie_consent_at: new cognito.StringAttribute({ mutable: true }),
},
passwordPolicy: { minLength: 12, requireLowercase:true, requireUppercase:true,
requireDigits:true, requireSymbols:true },
mfa: cognito.Mfa.OPTIONAL,
mfaSecondFactor: { sms: false, otp: true }, // TOTP only
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
featurePlan: cognito.FeaturePlan.ESSENTIALS, // $0.015/MAU (PLUS=$0.020, adaptive auth)
}); // removalPolicy RETAIN by default — never auto-delete
new cognito.UserPoolIdentityProviderGoogle(this, 'Google', {
userPool: pool, clientId: 'EXISTING_GOOGLE_CLIENT_ID',
clientSecretValue: SecretValue.secretsManager('google-oauth/client-secret'),
scopes: ['email','profile','openid'],
attributeMapping: { email: cognito.ProviderAttribute.GOOGLE_EMAIL },
});
const client = pool.addClient('WebClient', { // PUBLIC client, no secret (browser SPA)
authFlows: { userPassword: true, userSrp: true },
supportedIdentityProviders: [ ...Google, ...COGNITO ],
accessTokenValidity: Duration.hours(1), idTokenValidity: Duration.hours(1),
refreshTokenValidity: Duration.days(30), preventUserExistenceErrors: true,
});
// + addTrigger(PRE_SIGN_UP, ...), addTrigger(POST_CONFIRMATION, ...), customSenderKmsKey (stack B)
v2 note: advancedSecurityMode deprecated → featurePlan. Token caps: id/access 5min–1d, refresh 1h–3650d.
Custom-UI flow (no Hosted UI)
SDK = @aws-sdk/client-cognito-identity-provider v3. SignUpCommand → ConfirmSignUpCommand → InitiateAuth USER_SRP_AUTH (or USER_PASSWORD_AUTH simpler) → {Id,Access,Refresh}Token → REFRESH_TOKEN_AUTH. Verify JWTs against JWKS, never GetUser per request. Google = one redirect leg to the Cognito domain /oauth2/authorize?identity_provider=Google.
Deploy (AWS CLI MCP shells these)
export CDK_DEFAULT_REGION=us-east-2
aws secretsmanager create-secret --name google-oauth/client-secret --secret-string '…' --region us-east-2
cdk bootstrap aws://ACCOUNT_ID/us-east-2 # one-time
cdk deploy AuthStack --require-approval never --outputs-file cdk-outputs.json
# → COGNITO_USER_POOL_ID=us-east-2_xxxxx, COGNITO_CLIENT_ID=… → push to app env
B · SES (mail) + SNS (events) stack (CDK)
// SES domain identity + Easy DKIM (2048-bit) + config set + bounce SNS topic
const cfgSet = new ses.ConfigurationSet(this, 'AuthCfgSet', {});
const identity = new ses.EmailIdentity(this, 'DomainId', {
identity: ses.Identity.domain('callanalyst.app'), configurationSet: cfgSet,
}); // emits 3 DKIM CNAMEs
const repTopic = new sns.Topic(this, 'SesEvents'); // bounce/complaint → suppress
new ses.ConfigurationSetEventDestination(this, 'BounceDest', {
configurationSet: cfgSet, destination: ses.EventDestination.snsTopic(repTopic),
events: [BOUNCE, COMPLAINT, DELIVERY, OPEN, CLICK] });
// CustomEmailSender needs a KMS key (Cognito hands you the OTP KMS-encrypted)
const codeKey = new kms.Key(this, 'CognitoCodeKey', { removalPolicy: RETAIN });
const emailSender = new lambdaNode.NodejsFunction(this, 'EmailSender', { entry:'lambda/email-sender.ts' });
codeKey.grantDecrypt(emailSender);
emailSender.addToRolePolicy(new iam.PolicyStatement({ actions:['ses:SendEmail'], resources:['*'] }));
// on the pool: customSenderKmsKey: codeKey, lambdaTriggers: { customEmailSender: emailSender }
Lambda: AWS Encryption SDK decrypts event.request.code, branch on event.triggerSource (CustomEmailSender_SignUp / _ForgotPassword / …), build HTML, ses.sendEmail. Alt (zero code, cosmetic templates): UserPoolEmail.withSES({sesVerifiedDomain, fromEmail, sesRegion:'us-east-2', configurationSetName}).
Cloudflare DNS (DNS-only / grey-cloud)
| Type | Name | Value |
|---|---|---|
| CNAME ×3 | <tok>._domainkey.callanalyst.app | <tok>.dkim.amazonses.com |
| TXT (SPF) | callanalyst.app | v=spf1 include:amazonses.com ~all |
| TXT (DMARC) | _dmarc.callanalyst.app | v=DMARC1; p=quarantine; rua=mailto:dmarc@callanalyst.app; adkim=s; aspf=s |
SES sandbox = 200/day, verified recipients only → request production (Service Quotas) → 50k/day @14 msg/s, auto-scales. Bounce SNS topic is mandatory — bounce >5% / complaint >0.1% throttles the account. SMS MFA later: SNS role w/ sts:ExternalId, $1/mo sandbox cap — TOTP-first.
C · Cognito ↔ Stripe + TOS / UUID
post-confirmation Lambda — eager create, double-idempotent
export const handler = async (event) => {
const { sub, email, "custom:stripe_customer_id": existing,
"custom:tos_accepted_at": tosAt, "custom:tos_version": tosVer } = event.request.userAttributes;
if (existing) return event; // guard 1: already linked
const customer = await stripe.customers.create(
{ email, metadata: { cognito_sub: sub, tos_accepted_at: tosAt, tos_version: tosVer } },
{ idempotencyKey: `cust_create_${sub}` }); // guard 2: Stripe collapses retries
await cog.send(new AdminUpdateUserAttributesCommand({ UserPoolId: event.userPoolId,
Username: event.userName, UserAttributes:[{ Name:"custom:stripe_customer_id", Value: customer.id }] }));
return event; // MUST return event
};
Eager (every CA user is a billing subject) with lazy-at-first-checkout kept as self-heal. Gotcha: adding a custom attr can silently reset a trigger to None — re-attach.
TOS gate — pre-sign-up Lambda
// client: signUp({ ..., userAttributes:{ "custom:tos_accepted_at": now, "custom:tos_version":"2026-06-01" },
// clientMetadata:{ tos_accepted:"true", tos_version:"2026-06-01" } })
export const handler = async (event) => {
const md = event.request.clientMetadata || {};
if (md.tos_accepted !== "true" || !event.request.userAttributes["custom:tos_accepted_at"])
throw new Error("TOS_NOT_ACCEPTED"); // rejects sign-up, string surfaced to client
return event;
};
checkout + webhook (Cognito JWT)
const verifier = CognitoJwtVerifier.create({ userPoolId, tokenUse:"access", clientId }); // aws-jwt-verify, caches JWKS, build OUTSIDE handler
const claims = await verifier.verify(bearer); // ~2ms vs Supabase ~30-60ms; throws→401
const session = await stripe.checkout.sessions.create({ mode:"subscription",
customer: claims["custom:stripe_customer_id"], line_items:[{ price: PRICE[tier], quantity:1 }],
subscription_data:{ metadata:{ cognito_sub: claims.sub } }, success_url, cancel_url });
// webhook: cognito_sub = obj.metadata.cognito_sub ?? (await stripe.customers.retrieve(obj.customer)).metadata.cognito_sub
// → setEntitlement(cognito_sub, tier, status) in DynamoDB
PK=cognito_sub, attrs {tier,status,stripe_customer_id,stripe_sub_id,current_period_end,updated_at}, GSI on stripe_customer_id. Webhooks need fast idempotent writes (dedupe on event.id); /api/ask reads tier on the hot path. Cognito attrs are slow to mutate, eventual in tokens, 2500-char cap. Cognito holds only the link + legal attrs. Cookie consent = checkbox + banner → custom:cookie_consent_at (anon → cookie, backfill at sign-up).D · Sequence diagrams + timings p50 / p95
1 · Registration
sequenceDiagram
autonumber
participant B as Browser
participant Cog as Cognito
participant L as Lambda
participant Stripe
participant SES
participant DDB as DynamoDB
B->>Cog: SignUp(email,pw) ~120/300ms
Cog->>L: PreSignUp (TOS gate) ~40/120ms
L-->>Cog: allow
Cog->>SES: verification code ~250/700ms (SLOWEST, async)
Cog-->>B: CONFIRM_REQUIRED
B->>Cog: ConfirmSignUp(code) ~110/280ms
Cog->>L: PostConfirmation ~45/140ms
L->>DDB: PutItem profile{sub} ~8/25ms
L->>Stripe: customers.create(meta.cognito_sub) ~180/450ms
L->>DDB: UpdateItem stripe_customer_id ~8/25ms
L->>SES: welcome (Pro-branched) ~200/600ms
2 · Login + refresh
sequenceDiagram autonumber participant B as Browser participant Cog as Cognito B->>Cog: InitiateAuth USER_SRP_AUTH ~150/400ms (SLOWEST) Cog-->>B: PASSWORD_VERIFIER challenge B->>Cog: RespondToAuthChallenge (SRP proof) Cog-->>B: id+access+refresh JWT (RS256) Note over B: access TTL 1h, refresh 30d B->>Cog: REFRESH_TOKEN_AUTH ~90/220ms (silent) Cog-->>B: new id+access JWT
3 · Authenticated /api/ask (streaming)
sequenceDiagram autonumber participant B as Browser participant CF as CloudFront participant L as Lambda(ask) participant J as Cognito JWKS participant DDB as DynamoDB participant AI as Anthropic/OpenAI B->>CF: POST /api/ask + Bearer ~5ms edge CF->>L: forward ~10/30ms L->>J: JWKS (cached >99%) ~2ms / ~120ms cold L->>L: verify RS256 + claims ~2/4ms L->>DDB: GetItem tier + quota ~16/50ms L->>AI: messages stream=true first-token ~600/1400ms (SLOWEST) AI-->>B: SSE tokens ~30 tok/s L->>DDB: bump_usage (post-stream) ~10/30ms
JWKS-local verify ~2ms replaces Supabase /auth/v1/user ~30-60ms network call on every authed hop — net latency win.
4 · Notetaker bot
sequenceDiagram autonumber participant B as Browser participant L as Lambda participant R as Recall participant M as Zoom/Meet participant WH as recall-webhook participant DDB as DynamoDB B->>L: POST /api/recall/bot (JWT ~2ms) L->>R: POST /bot (join_url, streaming, logo) ~250/600ms L->>DDB: PutItem bot ~8/25ms R->>M: bot joins ~5-15s (human-scale, SLOWEST) loop live transcript R->>WH: webhook chunk (Svix HMAC ~1ms) ~50/150ms WH->>DDB: PutItem chunk ~8/25ms end loop client poll 2-3s B->>L: GET /api/recall/chunks?since= ~12ms end
5 · Calendar auto-join
sequenceDiagram
autonumber
participant B as Browser
participant L as Lambda
participant G as Google
participant P as calendar-poll(cron */2m)
participant R as Recall
participant DDB as DynamoDB
B->>L: oauth-start (JWT ~2ms)
L-->>B: redirect to Google
G->>L: callback?code
L->>G: exchange code→refresh_token ~200/500ms
L->>DDB: PutItem connection ~8/25ms
Note over P: every 2 min
P->>G: token refresh + list events(+30m) ~220/600ms (SLOWEST)
P->>R: POST /bot {join_at} ~250/600ms
P->>DDB: PutItem dispatch (dedupe)
6 · Checkout + webhook
sequenceDiagram
autonumber
participant B as Browser
participant L as Lambda(checkout)
participant S as Stripe
participant WH as stripe-webhook
participant DDB as DynamoDB
B->>L: POST /api/checkout (JWT ~2ms)
L->>DDB: GetItem stripe_customer_id ~6/20ms
L->>S: checkout.sessions.create(customer,coupon) ~280/700ms (SLOWEST)
S-->>B: redirect to Checkout
B->>S: pays (human)
S->>WH: checkout.session.completed (sig ~1ms) ~40/120ms
WH->>DDB: GetItem stripe_events (idempotency) ~6/20ms
WH->>S: subscriptions.retrieve (tier) ~200/500ms
WH->>DDB: UpdateItem {tier,status} ~8/25ms
US geofence (CDN)
CloudFront Function on viewer-request (sub-ms, ~$0.10/1M, redirect not bare 403, path-scoped). Built-in geo-restriction (allowlist US) is the zero-code alt but only 403s and can't scope paths.
function handler(event) {
var request = event.request, uri = request.uri;
var c = request.headers['cloudfront-viewer-country']; c = c && c.value;
if (uri.startsWith('/api/') || uri.startsWith('/webhooks/') || uri === '/blocked.html') return request;
if (c !== 'US') return { statusCode: 302, statusDescription:'Found',
headers: { location: { value: 'https://' + request.headers.host.value + '/blocked.html' } } };
return request;
}
Interim on Netlify (CloudFront not live yet): Edge Function reading context.geo.country.code.
export default (request, context) => context.geo?.country?.code !== 'US'
? new Response('US only', { status: 403 }) : context.next();
export const config = { path:'/*', excludedPath:['/api/*','/webhooks/*'] };
Soft geofence — VPN-bypassable, not legal-grade. Good enough to park GDPR for US-only launch; pair w/ TOS + "US only" at registration. CloudFront-Viewer-Country is auto-added before viewer-request fns.
Login method — recommendation
| Option | AWS-native | Custom-UI | $/mo @100k | DX | Lock-in |
|---|---|---|---|---|---|
| Cognito + custom UI pick | yes | full | $1,350 | mediocre | moderate |
| Cognito + Hosted UI | yes | poor | $1,350 | easy/ugly | moderate |
| Clerk fallback | no | excellent | ~$1k–1.2k | best | high |
| Auth0/Okta | no | good | $6k–7k+ | good | high+price |
| WorkOS | no | good | SSO-focused | good | moderate |
Cognito User Pools, custom UI. AWS-native (CDK construct, same IAM/CloudWatch), cheapest credible at scale, Google federation reuses the existing OAuth client. Fallback = Clerk (not Auth0) if Cognito DX (token refresh, custom-auth flows, thin docs) becomes painful. Enable: email/password + Google + TOTP MFA.
Migration (Supabase→Cognito) + open Qs for Saturday
Path
- No bulk password import in Cognito. Use a user-migration Lambda trigger — lazy-migrate on first Cognito sign-in by validating against Supabase, then create the Cognito user transparently. No mass reset. (Or force password-reset email to all — worse UX.)
- Phase: (1) deploy AuthStack + SES + triggers in us-east-2; (2) dual-run — new sign-ups → Cognito, existing → lazy-migrate; (3) move
/api/*JWT verify Supabase→aws-jwt-verify; (4) entitlement table backfill from Supabaseprofiles; (5) cut Supabase auth once migrated; keep Supabase Postgres/storage or move to Dynamo/R2 separately. - This rides on top of the module-split refactor (done, branch not merged) — the serverless functions are the seam where JWT verify swaps in.
Open questions for you
- Backend target: stay Netlify functions (swap JWT verify only) or move to Lambda behind CloudFront/API-GW as part of this? (Affects diagrams 3–6 participants.)
- Data: keep Supabase Postgres for projects/transcripts, or migrate to Dynamo? Entitlement is Dynamo regardless.
- SRP vs USER_PASSWORD_AUTH for the custom UI (security vs simplicity).
- Essentials vs Plus feature plan (adaptive auth worth +$650/mo at 100k?).
- Migration trigger now, or greenfield Cognito + force-reset (small current user base)?