The last post covered the edge gate in front of my private dashboard site: a CloudFront Function that checks a signed session cookie on every request. That gate is only as good as the code that decides who gets a cookie. This post is about that code, a dependency-free Lambda of a couple hundred lines that sits between a login page and the session.
What it replaced
The first version of the site used HTTP Basic authentication. The browser prompted for a username and password, sent them on every request, and the edge function compared them against a value in its key store. It worked, and over HTTPS it’s not a disaster. But it has no MFA, no lockout, no passkeys, no logout worth the name (browsers cache Basic credentials until they’re closed), and it meant the credential itself lived in my own infrastructure.
The replacement hands identity to Amazon Cognito and keeps my code in the role of a relying party. Cognito stores the passwords and passkeys. My Lambda only has to verify what Cognito tells it, and verifying is where all the interesting decisions are.
The flow, briefly
It’s the OpenID Connect authorization code flow with PKCE:
- The login button sends you to
/auth/login?next=/where/you/were. - The Lambda generates a random
state, a randomnonce, and a random PKCEcode_verifier, then redirects you to Cognito’s hosted sign-in page with the state, the nonce, and the SHA-256 of the verifier. - You sign in with a password or a passkey. Cognito redirects back to
/auth/callbackwith an authorization code. - The Lambda trades the code (plus the original verifier) for an ID token, checks everything, and if you pass, sets the session cookie.
The app client is public: there is no client secret anywhere. That sounds less secure, and it’s actually the point. A client secret has to live somewhere, and for a browser-driven flow there’s nowhere it’s truly secret. PKCE replaces it. Whoever redeems the authorization code has to present the verifier whose hash was sent at the start, and only the party that started the flow has it. An intercepted code is useless on its own.
Where the login state lives
The state, nonce, verifier, and destination path need to survive the round trip through Cognito. The Lambda has no database, so they ride in a short-lived cookie, hub_login, signed with HMAC under a login: prefix, scoped to Path=/auth/, and valid for ten minutes. It’s SameSite=Lax rather than Strict for a specific reason: the return from Cognito is a cross-site navigation, and Strict would withhold the cookie on exactly the request that needs it.
The ten checks
On the callback, a request has to clear all of these before it gets a session:
- A login cookie exists, its signature verifies, and it hasn’t expired. No cookie means this browser didn’t start a sign-in.
- The
stateparameter matches the one in the cookie, compared in constant time. This is the defense against login CSRF, where an attacker tricks your browser into completing a sign-in they started, so you end up logged in as them. - The code exchange succeeds with the stored PKCE verifier.
- The ID token is a well-formed JWT, three parts, decodable header and claims.
- The algorithm is exactly
RS256. The token’s own header says how to verify it, and trusting that blindly is how “alg confusion” andalg: noneattacks work. The code accepts one algorithm and rejects everything else. - The key ID is known. The signing key is looked up by
kidin the user pool’s published JWKS. An unknownkidtriggers one refresh of the key set, which handles key rotation, and a key that still isn’t there is a rejection. - The signature verifies against that public key.
- The issuer, audience, and token use are right:
issis my user pool,audis my app client, andtoken_useisid. A perfectly valid token issued for a different application is still the wrong token. - It hasn’t expired, and the
noncematches the one sent at the start. The nonce ties this token to this sign-in attempt, so a token captured from some other flow can’t be replayed into this one. - The email is verified and on the allowlist. A valid identity isn’t the same as an authorized one. The Lambda requires
email_verifiedto be true and the address to be on an explicit list.
Only then does it mint the session cookie and clear the login cookie.
That last check is enforced twice, on purpose. The user pool is admin-create-only with no self sign-up, and Terraform only creates users from the same allowlist. So an attacker would need to defeat both the pool configuration and the Lambda’s check. Belt and suspenders is the whole idea behind defense in depth.
The redirect that isn’t open
The next parameter is the classic place to build an open redirect, where /auth/callback?next=https://evil.example turns your login page into a phishing tool with a trustworthy domain. The validation is short and strict:
export function safeNext(v) {
if (!v) return '/';
try { v = decodeURIComponent(v); } catch (e) { return '/'; }
if (v.charAt(0) !== '/' || v.charAt(1) === '/' || v.charAt(1) === '\\') return '/';
if (/[\r\n]/.test(v)) return '/';
return v;
}
It has to start with a single slash, which rules out absolute URLs. It can’t start with // or /\, both of which browsers will happily treat as a protocol-relative URL to another host. And it can’t contain a carriage return or line feed, which would let someone inject headers into the redirect response. Anything suspicious falls back to the home page instead of erroring, which gives an attacker nothing to probe.
The configuration around the code
A few settings on the Cognito side carry as much weight as the code:
- Passkeys are allowed as a first factor. A passkey is bound to the sign-in domain, so a convincing phishing page on a lookalike domain simply can’t use it. That’s phishing resistance that no amount of user training gets you.
- Passwords need 16 characters, with the usual complexity rules on top. If I keep password sign-in around long term, adding MFA to it is one setting.
- User existence errors are suppressed, so the sign-in page gives the same answer for a wrong password and a nonexistent account. That stops it from being used to enumerate valid emails.
- Cognito’s tokens live five minutes. The Lambda uses the ID token exactly once, at the callback, and nothing downstream ever holds a Cognito token. The short lifetime shrinks the window if one leaks.
The Lambda’s own IAM role can write to its log group and read exactly one SSM parameter, the session secret. Its function URL requires IAM authorization, and CloudFront signs requests to it through Origin Access Control, so the Lambda can’t be called directly from the internet. It has zero npm dependencies. The deployed artifact is one file, which makes the supply chain review take about as long as reading it.
Logout clears the session cookie and also ends the Cognito session, so the next sign-in is a real sign-in and not a silent redirect.
If you’re writing a relying party
Most of this list is boilerplate in a mature OIDC library, and in a larger app I’d use one. But it’s worth knowing what the library is doing for you, because these are exactly the checks that get skipped in hand-rolled integrations: pin the algorithm, check issuer and audience, validate the nonce and state, separate authentication from authorization, and never trust a redirect target you didn’t validate. Each one closes a specific, well-documented attack. None of them is optional.