OIDC Login in the Real World and How to Debug Cognito Hosted UI Without Losing Your Mind

January 9, 2025

Who this is for: engineers shipping web apps, and founders who want to understand why “login” is never “just login.” The uncomfortable truth Most “auth bugs” aren’t bugs. They’re protocol misunderstandings hiding behind a button that says “Sign in.” OIDC (OpenID Connect) is simple in theory: OAuth 2.0 handles authorization. OIDC adds identity (who the user is) via the ID token. In practice, your app is a bunch of moving parts, and Cognito is strict (as it should be). This post gives you: the exact redirect trace for Cognito Hosted UI PKCE explained in a way you can actually implement token reality: ID vs access vs refresh the pitfall list that causes 90% of production pain a security posture I’d defend in a serious review The flow in one diagram (the only mental model you need) You’re doing Authorization Code Flow with PKCE (best practice for browser apps). The user gets redirected to Cognito, logs in, Cognito redirects back with a short-lived code, and your app exchanges that code for tokens at the token endpoint. Sequence diagram (Figma spec) Create 4 vertical swimlanes: Browser, Your App (Frontend), Cognito Hosted UI, Your API. Browser → Cognito: GET /oauth2/authorize (with code_challenge, state, nonce) Cognito → Browser: redirects to login UI Browser → Cognito: user authenticates Cognito → Browser: 302 redirect back to your redirect_uri with ?code=...&state=... Frontend → Cognito: POST /oauth2/token (with code_verifier) Cognito → Frontend: returns tokens Frontend → API: Authorization: Bearer <access_token> The actual redirect trace (Cognito Hosted UI) 1) Your app sends the user to the authorization endpoint Cognito’s authorize endpoint is: https://<your-domain>.auth.<region>.amazoncognito.com/oauth2/authorize Example: GET https://YOUR_DOMAIN.auth.eu-west-2.amazoncognito.com/oauth2/authorize ?client_id=YOUR_CLIENT_ID &response_type=code &scope=openid%20email%20profile &redirect_uri=https%3A%2F%2Fapp.yoursite.com%2Fauth%2Fcallback &state=RANDOM_CSRF_STRING &nonce=RANDOM_NONCE &code_challenge=BASE64URL_SHA256(code_verifier) &code_challenge_method=S256 Key points: response_type=code is the flow you want. scope must include openid if you want OIDC identity (ID token). state is for CSRF protection (you must validate it on return). nonce is for ID token replay protection (you must validate it if present). code_challenge_method=S256 is the safe PKCE mode. 2) Cognito redirects back with a code After login, Cognito redirects to your callback: https://app.yoursite.com/auth/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=RANDOM_CSRF_STRING At this point, you have no tokens yet. You only have a code. 3) Your app exchanges the code for tokens Cognito’s token endpoint: https://example.com.auth.<region>.amazoncognito.com/oauth2/token Request: POST /oauth2/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code& client_id=YOUR_CLIENT_ID& code=THE_CODE_YOU_GOT& redirect_uri=https%3A%2F%2Fapp.yoursite.com%2Fauth%2Fcallback& code_verifier=YOUR_ORIGINAL_CODE_VERIFIER If it works, Cognito returns tokens. And here’s a detail people miss: In Cognito, the authorization code grant is the only flow that can return ID + access + refresh tokens together. PKCE explained like a normal person PKCE exists because SPAs are “public clients.” You can’t safely hide a client secret in a browser. So PKCE binds the flow to the client that started it. You generate: code_verifier = random high-entropy string (store it temporarily) code_challenge = BASE64URL(SHA256(code_verifier)) You send code_challenge in /authorize, and later you send code_verifier to /token. The server checks they match. That blocks “stolen code” attacks. Important: use S256 and don’t accept downgrades to “plain.” RFC 7636 explicitly warns against downgrade behavior. Tokens: what you got back and what they’re for Cognito returns up to three tokens: ID token (JWT) Identity. “Who is this user?” Used by your frontend to show user info and confirm the login session. Access token (JWT) Authorization. “What can this user do?” Used in Authorization: Bearer ... to call APIs. Cognito access tokens include scopes/groups/claims and are meant for access control. Refresh token (opaque) Session continuation. Cognito refresh tokens are encrypted and opaque (you can’t decode them like JWTs). Token lifecycle diagram (Figma spec) Draw three horizontal bars: ID token (short), access token (short), refresh token (long). Annotate: access token expires quickly refresh token used to mint new access token ID token not used for API auth The 8 production pitfalls that waste weeks 1) Callback URL mismatch (the classic) Cognito is strict about redirect_uri. If your request callback URL doesn’t exactly match what you configured, it fails. This is the number one “it works locally but not in prod” issue. 2) Confusing user pool domain vs discovery domain Cognito login endpoints live on your user pool domain, but discovery endpoints live on a different hostname: https://cognito-idp.<region>.amazonaws.com/<userPoolId>/.well-known/openid-configuration If you’re verifying tokens, you’ll likely also need JWKS: https://cognito-idp.<region>.amazonaws.com/<userPoolId>/.well-known/jwks.json 3) Skipping state validation If you don’t validate state, you’re inviting CSRF-style attacks into your login flow. Your “Sign in” becomes “Sign in as someone else.” 4) Skipping nonce validation OIDC says if nonce is in the ID token, the client must verify it matches what was sent. It’s there to mitigate replay. 5) Wrong scopes If you forget openid, you’re not doing OIDC properly, and you’ll wonder why you’re not getting an ID token or user identity claims. 6) Expecting refresh tokens from the wrong flow If you use the wrong grant/flow, you’ll miss refresh tokens and then you’ll “mysteriously” log users out constantly. Cognito specifically notes the authorization code grant as the path to all three token types. 7) Token storage decisions you’ll regret If you throw tokens into localStorage because it’s easy, you just made XSS dramatically more expensive. This isn’t theoretical. It’s why OAuth security guidance keeps pushing safer patterns and deprecating weaker modes. 8) Treating the ID token as an API credential Your API should validate access tokens, not ID tokens. Different purpose, different semantics. My security posture for SPAs (what I do in real life) This is the part that separates “I got it working” from “I can defend it.” What I refuse to do Store tokens in localStorage as the default Use implicit flow “because it’s easier” Skip state/nonce checks Let the frontend “self-validate” auth without server confirmation for protected actions OAuth security best practice has been evolving for years, and the modern posture is pretty clear: use authorization code + PKCE, avoid legacy patterns, and implement the checks that exist for a reason. What I do instead (practical options) Pick one based on your architecture: Option A: Backend-for-Frontend (BFF) Frontend never touches refresh tokens. Backend stores tokens server-side and issues a session cookie (HttpOnly, Secure, SameSite). Best for serious products where auth is business-critical. Option B: SPA-only with tight controls Use code+PKCE. Store tokens in memory (not persistent storage). Rotate with refresh token only if absolutely necessary, and treat XSS prevention as a top-tier requirement. If you’re a founder: Option A is usually worth it because it reduces breach blast radius and compliance stress. Debug checklist (copy this into your runbook) When login fails: Confirm you’re hitting the correct /oauth2/authorize endpoint and domain. Confirm redirect_uri matches config exactly (scheme, host, path, trailing slash). Confirm you are generating and sending: state and validating it on return nonce and validating it against the ID token claim PKCE S256 (code_challenge_method=S256) and sending code_verifier to /oauth2/token Confirm the token exchange is against /oauth2/token and includes the same redirect_uri. Validate tokens using Cognito JWKS and discovery endpoints (don’t hardcode keys). Confirm your API expects the access token, not the ID token. Closing thought Auth is a revenue feature disguised as plumbing: If it breaks, users churn. If it’s weak, you inherit existential risk. If it’s well-designed, you buy speed everywhere else.

Interested in updates on new npm releases?

Sign up with your email and get fresh updates as soon as they drop.