The "Sign in with Google" button on this site looks simple: click, pick an account, come back signed in. Behind it is a protocol between three parties who don't fully trust each other, running through your browser, an environment none of them controls. This post walks through that protocol step by step, following viet.sarl's own code.
Roadmap: the problem OAuth solves, the roles, the authorization code flow step by step, state, PKCE (with a simulation), the kinds of token, OpenID Connect, the flows that were dropped, common integration mistakes, then how this site decides which account a Google sign-in lands on. The simulation runs with Python 3.12 on a real machine.
The problem: granting access without handing over a password
Before OAuth, an app that wanted to read your Gmail contacts would ask for your Gmail password. That app had full control of the account, forever, until you changed the password, and you had no way to let it read only your contacts.
OAuth 2.0 (2012) replaced that with limited delegation: you sign in directly with Google, Google asks "let app X read your contacts?", then gives X an access token that can do only that, expires, and can be revoked. App X never sees the password.
A common confusion: OAuth is an authorization protocol, not an authentication one. An access token says "whoever holds this may do Y", not "whoever holds this is so-and-so". The "who" part is added by OpenID Connect, below.
The roles
| Role | In this site's example |
|---|---|
| Resource owner | You |
| Client | viet.sarl |
| Authorization server | accounts.google.com: signs you in, asks for consent, issues tokens |
| Resource server | Google's API returning profile information (userinfo) |
Google issues viet.sarl a client_id (public) and a client_secret (secret, kept in .env on the server), plus a list of pre-registered redirect URIs such as https://viet.sarl/auth/oauth/google/callback.
The authorization code flow, step by step
Browser viet.sarl Google
│ clicks "Google" ►│
│ │ makes state + verifier, stores them in a cookie
│◄── redirect to Google (client_id, redirect_uri, scope, state, code_challenge)
│ ─────────────────────────────────────────────────►│ sign in, consent
│◄── redirect to callback?code=...&state=... ────────│
│ ───────────────►│ checks state against the cookie
│ │ ── POST code + client_secret + verifier ──►│ checks
│ │ ◄── access_token ──────────────────────────│
│ │ ── GET userinfo (Bearer token) ────────────►│
│ │ ◄── sub, email, email_verified, name ──────│
│◄── sets sign-in cookies, redirects back to the original page
Step 1, the site makes two random numbers with secrets (post 1) and sends the user to Google:
def new_state() -> tuple[str, str]:
"""(state, PKCE code verifier)."""
return secrets.token_urlsafe(24), secrets.token_urlsafe(48)
params = {
"client_id": self.client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": self.scope, # "openid email profile"
"state": state,
"code_challenge": challenge, # a hash of the verifier, below
"code_challenge_method": "S256",
}
state and verifier are stored in the oauth_state cookie. It's a signed JWT, HttpOnly, only sent to /auth/oauth, and lives for 10 minutes, so the server doesn't have to store anything.
Step 2, Google sends the user back to the callback with an authorization code: a short-lived, single-use code. It travels through the browser, i.e. through the address bar, browser history and possibly logs, so the code itself isn't a token.
Step 3, the site's server swaps the code for a token over a direct channel from server to Google (TLS, post 5), along with client_secret and code_verifier. The token never passes through the browser.
state: not getting signed into someone else's account
When the callback is called, how does the site know that redirect started from this very browser? Without a check, another site could lead your browser to the callback with a code from their account. You'd be signed into someone else's account without knowing it, and everything you did afterwards, like saving scores or entering details, would land in their account. It's a form of CSRF on the sign-in flow.
state blocks that: the random value sent out must match the value coming back, and the original value sits in the browser's own cookie. This site compares them with a comparison that doesn't leak timing (post 2):
saved = tokens.read_oauth_state(oauth_state) if oauth_state else None
if not (code and state and saved and secrets.compare_digest(state, saved.state)):
... # refuse, back to /login?error=oauth
PKCE: a leaked code is useless
PKCE (Proof Key for Code Exchange, pronounced "pixy", RFC 7636) was first made for mobile apps, which can't keep a client_secret and where another app on the device can register the same kind of redirect. It's now recommended for every client:
- Before redirecting, the client makes a random verifier and keeps it secret.
- The client sends a challenge = base64url(SHA-256(verifier)). The hash is one-way, so the challenge doesn't reveal the verifier.
- The authorization server remembers the challenge along with the code.
- When swapping the code, the client must send the verifier. The server hashes it and compares it with the remembered challenge.
Anyone who gets the code from the address bar or a log still doesn't have the verifier, because the verifier never left the client's server (for this site, it lives in a signed HttpOnly cookie).
Simulation: a small authorization server in Python that remembers the challenge for each code, deletes the code right after its first use, and checks the verifier with compare_digest. Three cases: someone with the code but not the verifier (attacker), the real app (real app), and reusing the code a second time (replay):
verifier len 64 challenge gAh5gt-r1nrlDr8mb4PoaaNfhGJZnjaTWILcd9TuseQ
attacker : invalid_grant (PKCE verifier mismatch)
real app : access_token=x6CnFXDOzQs
replay : invalid_grant (unknown or used code)
Three rules the authorization server must keep: the code is bound to the challenge, the code is single-use, and the code is bound to the client_id and redirect_uri. Miss one and a hole opens.
The kinds of token
| Token | For | Content | Lifetime |
|---|---|---|---|
| Access token | The resource server (API) | What's allowed, for which client | Short: minutes to an hour |
| Refresh token | The authorization server | Used to get new access tokens | Long, and revocable |
| ID token (OIDC) | The client | Who the user is: sub, email, name... |
Short, used once at sign-in |
Scope limits the access token: this site only asks for openid email profile, i.e. who you are, with no access to Gmail or Drive. That's the principle of least privilege: ask for as little as possible, because a leaked token can only do what its scope allows, and users are more willing to consent.
Note that Google's tokens are only used during sign-in. After that the site issues its own tokens (its own short-lived session cookies) and keeps nothing from Google.
OpenID Connect: adding the "who"
OpenID Connect (OIDC, 2014) is a thin layer over OAuth 2.0. Add the openid scope and the authorization server also returns an ID token: a JWT signed by Google containing the user's identifier (sub, stable for the account's whole life), the email, whether it's verified, and aud (which client the token is for).
The client has two ways to learn who the user is:
- Check the ID token: verify its signature with Google's public keys (published at a JWKS address), and check
iss,aud,expandnonce. - Call the userinfo endpoint with the access token it just got.
This site picks option 2. A comment in the code explains why it can skip the signature check:
# Read straight from the provider over TLS with our own token, so no ID-token signature check needed
info = await client.get(self.userinfo_url, headers={"Authorization": f"Bearer {token.json()['access_token']}"})
The information comes straight from Google over TLS, using a token the site itself just obtained with the code, secret and verifier. There's no place for anyone else to step in. The cost is one extra network request per sign-in, negligible.
The identifier used to link accounts is sub, not the email. Emails can change, and an address can be reassigned to someone else. sub can't. The oauth_account table has a UNIQUE(provider, provider_user_id) constraint so one Google identity belongs to only one account.
The flows that were dropped
- Implicit flow: the authorization server returned the access token directly in the URL (after the
#), with no code exchange. The token ended up in browser history, could leak through theRefererheader, and couldn't be bound to the right client. It used to be the default for apps running entirely in the browser. Now they use authorization code + PKCE. - Resource owner password credentials: the client asks the user's password directly and sends it to the authorization server, which is exactly the problem OAuth was born to solve.
OAuth 2.1 (being finalised) collects these lessons: it drops both flows above, requires PKCE for every authorization code flow, requires redirect URIs to match exactly, character for character, and requires refresh tokens for public clients to be rotated or bound to the device.
Common integration mistakes
| Mistake | Consequence | How to avoid it |
|---|---|---|
Not checking state |
Getting signed into someone else's account | Random, stored in a cookie, compared with compare_digest |
| Loose redirect URI matching (prefixes, wildcards) | The code is sent to someone else's site | Register full URIs; the server compares exactly |
A next parameter accepting any URL |
Open redirect: a real link to the site leads users to a phishing page | Only accept local paths |
| Trusting an email the provider hasn't verified | Taking over an existing account with the same email | Only link when email_verified is true |
Linking by email instead of sub |
A changed or reassigned email changes the account's owner | Use sub |
| Asking for broad scopes | A leaked token can do a lot | Least privilege |
This site handles each row. The ?next= parameter only accepts paths starting with / on the site itself (safe_next). The "trusting an unverified email" row gets the most careful handling, below.
Which account a Google sign-in lands on
sign_in_with_provider checks in order:
- This Google identity is already linked to an account → sign into that account.
- Someone is signed in → link the identity to them.
- An account has the same email and Google says the email is verified → link to that account.
- Otherwise → create a new account.
Step 3 has a subtle detail. Suppose someone registered with a password using the email [email protected] who isn't An, since the site hadn't verified that email. Then the real An signs in with Google. With a plain link, whoever registered first would keep a password into An's account. The code handles it:
if user is not None and not user.email_verified:
# Nobody proved they own this email when the account was made, but the provider just did. Drop the
# password so whoever registered it can't keep a way in to the real owner's account.
password_removed = user.password_hash is not None
await session.execute(
update(User).where(User.id == user.id).values(email_verified=True, password_hash=None)
)
Conversely, if Google reports the email as not verified and it matches an existing account, the sign-in is refused: an email nobody has checked proves nothing about owning the account.
Summary
- OAuth 2.0 is limited delegation without handing over a password. OpenID Connect adds "who the user is" (ID token, userinfo).
- The flow to use: authorization code + PKCE. The code travels through the browser; tokens go directly between servers over TLS.
statestops you being signed into someone else's account. PKCE makes a leaked code useless without the verifier (simulation:invalid_grant). Codes are single-use.- Ask for minimal scopes. Link accounts by
sub, not email, and only trust verified emails. - The implicit flow and the password grant are gone. OAuth 2.1 requires PKCE and exact redirect URI matching.
The next post moves from the browser to the command line: SSH. Host keys and known_hosts, signing in with ed25519 keys, the post-quantum key exchange OpenSSH already turns on by default, ssh-agent, and hardening a server.