Ndiro never sees a password. Sign-in is delegated to Google via OAuth 2.0, identity is carried in a signed cookie, and authorization is re-checked from the database on every request. This chapter walks the whole chain, because it's the part of web development with the least room for improvisation.
browser the app Google
โ GET /login โโโโโโโโโโโโโโโโโโโโโถ โ
โ โ session['oauth_state'] = random
โ โโ 302 to accounts.google.com โโ โ (and login_next = where to land after)
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโถ โ
โ user picks their Google account, consents โ
โ โโ 302 back to /callback?code=โฆ&state=โฆ โโโโโโโโโโโโโโโโโโโโโโ โ
โ GET /callback?code=โฆ&state=โฆ โโโถ โ
โ โ 1. state == session.pop('oauth_state')?
โ โ 2. POST the code + client_secret โโโโโถ โ
โ โ โโโโโโโโโโโโ access token โโโโโโโโโโโ โ
โ โ 3. GET userinfo (sub, email, name) โโโถ โ
โ โ 4. upsert users row; session['user_id']=sub
โ โโ 302 to /log, Set-Cookie โโโโโ โ
Three details carry the security weight. The code exchange
happens server-to-server (step 2, in
auth.fetch_userinfo) โ the browser only ever sees the
one-time code, never the client secret or the token. The
state parameter is a random nonce stored
in the session and compared with session.pop on return; a
forged or replayed callback fails the comparison. And the account key is
Google's sub (a stable ID, treated as an
opaque string โ never parsed), not the email โ emails change; primary
keys shouldn't.
One more guard hides in plain sight: _safe_next. The
?next= destination that rides through login is validated to
be a same-site relative path โ starts with /, not
//, no backslashes โ otherwise
/login?next=https://evil.example turns your trusted domain
into an open redirect for phishing.
Flask's default session is a cookie containing your data as base64
JSON plus an HMAC signature keyed by SECRET_KEY. Anyone can
read it (decode yours โ it's just
{"user_id": "โฆ"}); nobody can modify it without
the signature check failing. Forge-proof, not secret. Which is exactly
why the rule exists:
# Session stores ONLY user_id (the Google sub) plus transient oauth_state /
# login_next. Status is NEVER cached in the cookie: every guarded request does
# a fresh users-table read (~$0.25/million) so a rejected user's live session
# dies immediately.
(One more transient rides the session during an invite sign-up:
invite_token, stored by /login and popped
unconditionally in /callback so it never survives into the
signed-in session โ invariant #4 lists all three transients.)
A cookie is a cache you cannot invalidate โ it lives on someone
else's machine for 30 days. Cache status: approved in it
and a banned user stays approved until expiry. So the cookie holds only
the identity pointer, and authorization data is read fresh per request.
Revocation latency: zero. Cost: one cheap key lookup.
The cookie's attributes are set once in app.py and each
earns its place:
| Flag | Protects against |
|---|---|
Secure | Transmission over plain HTTP (only sent on HTTPS) |
HttpOnly | JavaScript reading it โ even injected script can't exfiltrate the session |
SameSite=Lax | Other sites triggering authenticated writes (the CSRF defence โ the security chapter) |
A users row has status โ pending | approved | rejected |
admin. New sign-ups land pending (the waiting page)
until an admin approves them โ or they arrived through an invite link,
which auto-approves. The two decorators from the
backend chapter enforce it all;
the branchy middle of approved_required is the actual
policy: approved/admin โ proceed with g.user set; pending โ
the waiting page (or 403 for API calls); rejected or deleted โ
session.clear() and goodbye. API paths get JSON errors,
page paths get redirects โ _wants_json() switches on the
/api/ prefix so both callers get an answer they can
handle.
ADMIN_EMAILS is only a bootstrap: it decides the status
written at first sign-in. After that the table is the truth โ
an admin is whoever's row says admin, and the env var is
never consulted at request time.
Invite links (/i/<token>) are single-use,
expiring, auto-approving. The interesting part is the redemption:
marking an invite used must be race-safe (two people clicking the same
link at once), so db.claim_invite is a DynamoDB
conditional write โ "set used_by, if
used_by doesn't exist and it isn't revoked or expired" โ
an atomic compare-and-set at the storage layer, not a read-check-write
in Python. The MAX_USERS capacity check runs
before invite logic, so a full instance never burns someone's
invite.
The cookie is a Kerberos-ish ticket: self-contained proof of identity, honored without a round trip. The fresh status read is the deliberate opposite โ a per-request ACL check against the authority โ because for a 100-user app, "revocation is instant" is worth a millisecond per request. Recognize the trade: tickets scale reads, authority lookups scale trust.
Devtools โ Application โ Cookies. Find the
session cookie, copy its value, and decode the first
segment (it's URL-safe base64 of JSON โ in a terminal:
echo 'PASTE' | cut -d. -f1 | base64 -d). There's your
user_id, readable but untamperable. Note
HttpOnly is checked โ now try
document.cookie in the Console and see the session
cookie refuse to appear.