OAuth 2.0 and OpenID Connect: “Sign in with Google”, properly
Monday, 9:00: 34% of new guests abandon StayHub at the password form, and the 120-hotel chain wants its 2,400 staff to log in with their company accounts. Can StayHub do both without ever seeing their passwords?
How can an app use your Google Calendar without ever seeing your Google password? That's OAuth, and "Sign in with Google" builds on it.
- Problem
A hotel app (StayHub) wants guests to sign in with their Google account, and later to add bookings to their Google Calendar, without StayHub ever handling Google passwords.
- Why the simple system fails
Asking guests for their Google password gives StayHub total, permanent access and trains users to be phished. Ad-hoc redirect schemes that pass tokens in URLs, skip state, or trust any email claim let attackers log in as someone else.
- Engineering concept
Delegation: the user authenticates at the provider and consents to specific scopes; the app receives a one-time code, redeems it over a back channel (proved by PKCE) for short-lived tokens, and verifies a signed id_token to learn who the user is.
- Technology
OAuth 2.0 authorization-code flow + PKCE, state and nonce, scopes, access tokens, refresh tokens, OpenID Connect id_token (JWT) verified with the provider's JWKS.
- Trade-offs
Social login removes password storage and signup friction but adds a dependency on the provider, redirect complexity, and account-linking edge cases. Running your own authorization server is a large, security-critical project; most teams use a provider or a hosted identity service.
The problem
2 min- Maya · Product manager34% of new guests quit at the email + password form. They want "Sign in with Google" or Apple.
- Karim · CTOAnd the 120-hotel chain wants its 2,400 staff to log in with their company accounts.
- Lina · Senior engineer, your mentorSame idea for both: someone else checks the password.
The big idea
4 minOAuth gives an app limited access to an API without the password; OpenID Connect adds a signed statement of who logged in.
Like a hotel valet: you never hand over the master key. The valet gets a valet key that only starts the car, today. You get a signed guest card that proves who you are, and it doesn't start the car.
How it works
5 min- 1Send the guest to Google
With our client_id, an exact redirect_uri, scopes
openid email profile(what we may ask for), a random , a nonce and a challenge: the hash of a secret only our server keeps. - 2Google checks the guest
Password, two-factor, consent screen. StayHub never sees any of it.
- 3Back with a code
Google redirects the browser to our callback with a one-time code and the same state. We compare state with this browser's session: that stops someone pushing their login into a guest's browser.
- 4Swap the code, server to server
We send code + code_verifier + client secret straight to Google. A thief who grabbed the code from the URL lacks the verifier, so PKCE stops them.
- 5Verify the id_token
The is a signed about the guest. Check signature, issuer, audience = our client_id, expiry, nonce. The access token is different: it's for calling an API, never proof of identity.
- 6Find the account, start a session
Key the user on (issuer, sub), the provider's stable id. Email is just a label. Then issue our own HttpOnly session cookie.
Watch a decision being made
4 minYou write the callback, Lina reviews
Write GET /auth/callback for "Sign in with Google". The redirect already stored state, nonce and the PKCE verifier in the guest's session.
- 1Check state first
Compare the
statein the URL with the session's, in constant time. Mismatch → 400.Why: Lina: "Without it, an attacker pushes their login into a guest's browser, and the guest saves a card into the attacker's account."
- 2Swap the code, then verify the id_token
Send code +
code_verifierto Google's token endpoint. Verify the id_token, then the nonce.tsconst claims = await verifyJwt(tokens.id_token, { jwksUrl: GOOGLE_JWKS_URL, issuer: "https://accounts.google.com", audience: CLIENT_ID, }); if (claims.nonce !== session.oauthNonce) throw new Error("nonce mismatch");Why: A valid Google token minted for another app must not log anyone into StayHub. The audience check stops that.
- 3Key the account on (iss, sub)
Find or create the user by issuer + subject. Store the email as a contact detail, with
email_verified.Why: Emails change, may be unverified or hidden behind a relay.
subis stable. - 4Ask for more only when needed, then hand over
No calendar access now; request
calendar.eventswhen a guest taps "Add to calendar". Regenerate the session id, setuserId, discard the id_token.Why: Big consent screens scare guests, and a broad leaked token does broad damage. A fresh session id prevents session fixation.
In real life
3 minSign in with Google: the real address
- Shares the account's email (with consent) and whether it's verified
- Tempting to use the email as the account key
Easy to integrate; don't mistake the email for the identity.
Sign in with Apple: the option to hide it
- Publicly offers "Hide My Email": each app gets a unique relay address
- Forces apps to rely on the stable user id
More private; breaks any login built on email.
What goes wrong
3 minEmails get reassigned, can be unverified, and differ between providers.
Key on (issuer, sub); link to an existing account only from a logged-in session with a verified email.
A token issued to another app for the same user could be replayed to log in to yours.
Authenticate with the id_token and check aud, iss, exp and nonce.
URLs leak; localStorage is readable by any XSS; bearer tokens work for whoever holds them.
Tokens server-side, refresh tokens encrypted, only an HttpOnly session cookie in the browser.
Trade-offs
3 min| Option | Fits when | You pay |
|---|---|---|
| Own email + password | Guests without Google/Apple; offered alongside | Storing hashes, building resets and two-factor |
| Social login via OIDC, built with a library | Consumer signup friction matters | You own the callback code; provider outages block logins |
| Hosted identity broker | Many providers, enterprise SSO, no auth team | Per-user fees, lock-in, one more critical dependency |
- Karim · CTOBuy a hosted identity service: Google, Apple, the chain's SSO, one integration. About €50,000 a month at ~9 million monthly active guests.
- Lina · Senior engineer, your mentorGoogle and Apple are two well-documented integrations. Build those with a mature library; use a broker only for enterprise SSO.
- Karim · CTOThen we own security-critical code.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- Which flow: authorization code with , state and nonce. An agent may copy an older tutorial with the implicit flow; you decide, and you check.
- What identifies an account: issuer plus
sub, never the email. - Which and when:
openid email profileat sign-in; calendar access only when the guest asks for it.
Add Sign in with Google to the guest web app. Plan first, wait for my OK. Use code flow with PKCE, state and nonce through a maintained OIDC library. Scopes: openid email profile. On the callback: check state, exchange the code server-side, verify the id_token's signature, issuer, audience equal to our client id, expiry and nonce. Key users on (issuer, sub); never link accounts by email. Then set our own HttpOnly session cookie. Add tests for a wrong state, a wrong audience and an expired token.
- The redirect URL Claude shows contains
code_challengeandstate, and no secret. - The callback has all five id_token checks; a missing audience is the classic hole.
- The user lookup searches by issuer and
sub, notemail.
- It uses
response_type=token(implicit flow) or drops PKCE because "we have a client secret". - It logs in or merges accounts by matching the email claim.
- It stores tokens in localStorage or prints them in logs "for debugging".
Check yourself
2 minNext episode: a review on a hotel's page starts quietly changing the email address of every guest who opens it.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You write the callback, Lina reviews
Write GET /auth/callback for "Sign in with Google". The redirect already sent state, nonce and a PKCE challenge, stored in the guest's session.
- 1Check state first
Compare the
statein the URL with the one in this browser's session, in constant time. Mismatch → 400.Why: Lina's review: "Without it, an attacker can push their own login into a guest's browser (login CSRF), and the guest saves a card into the attacker's account."
- 2Swap the code on the back channel
POST the code, the
code_verifier, our client id and secret to Google's token endpoint.tsconst tokens = await fetch(GOOGLE_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, client_secret: process.env.GOOGLE_CLIENT_SECRET!, code_verifier: session.pkceVerifier, }), }).then((r) => r.json());Why: The verifier proves we started this login. A stolen code is useless without it.
- 3Verify the id_token
Check the signature with Google's published keys (JWKS), then
iss,aud= our client id,exp, and thenoncefrom the session.tsconst claims = await verifyJwt(tokens.id_token, { jwksUrl: GOOGLE_JWKS_URL, issuer: "https://accounts.google.com", audience: CLIENT_ID, }); if (claims.nonce !== session.oauthNonce) throw new Error("nonce mismatch");Why: A valid Google token minted for another app must not log anyone into StayHub. The audience check is what stops that.
- 4Key the account on (iss, sub)
Find or create the user by issuer + subject. Store the email as a contact detail, with
email_verified.Why: Emails change, get reassigned, may be unverified or hidden behind a relay.
subis the provider's stable id for this person. - 5Ask for more only when needed
Don't request calendar access now. When a guest taps "Add to calendar", request
calendar.eventsthen, and store that refresh token encrypted, server-side.Why: Big consent screens scare guests away, and a leaked token with broad scopes does broad damage.
- 6Start our own session
Regenerate the session id, set
userId, and redirect to /my-bookings. Discard the id_token.Why: A fresh session id prevents session fixation. After login, StayHub's own HttpOnly cookie is what keeps the guest signed in.
| Artifact | Lifetime (typical) | Audience | Where it lives |
|---|---|---|---|
| Authorization code | Seconds to ~10 min, single use | Our token request | Redirect URL (public) |
| Access token | Minutes to ~1 h | The API (e.g. Google Calendar) | Server-side only |
| Refresh token | Days to months, often rotated | The authorization server | Encrypted, server-side only |
| id_token | Minutes to ~1 h | StayHub (aud = client_id) | Verified once, then discarded |
| StayHub session cookie | Our choice | StayHub | HttpOnly, Secure cookie |
Interactive simulation
Visual diagram
Authorization-code flow with PKCE and OIDC. Solid lines through the browser are the front channel; the token exchange is a direct back channel.
Analogy
- A hotel valet keyYou keep your master keyOAuth and OIDCYou never share your password
- A hotel valet keyValet key starts the car, can't open the bootOAuth and OIDCAccess token with limited scopes
- A hotel valet keyValet key only works todayOAuth and OIDCToken expires quickly
- A hotel valet keyFront desk checks your ID firstOAuth and OIDCThe login server
- A hotel valet keyA signed guest card proving who you areOAuth and OIDCThe OIDC id token
Where the analogy breaks: a valet key is physical, so only one person holds it. A token is just text, so anyone who copies it can use it. That's why tokens must stay short-lived and out of URLs and logs.
Check your understanding
Practice ladder
Which check stops which attack?
Scenario Warm-upStayHub's login uses code + PKCE + state + nonce, verifies the id_token's signature, iss, aud and exp, and keys accounts on (iss, sub). Trace each attack and name the check that stops it:
- An attacker gets a valid Google id_token for their own account that was issued to a different app, and sends it to StayHub.
- An attacker starts a login with their own Google account, stops at the callback, and tricks a guest into opening
/auth/callback?code=ATTACKER_CODE&state=…. - A malicious app on a guest's phone intercepts the code from the redirect and calls the token endpoint itself.
Fix the account lookup
Code PracticeLina's review left two gaps in the login handler after the id_token is verified.
// claims = verified id_token claims
let user = await /* gap 1: find the existing account */;
if (!user) {
user = await db.user.create({
data: { iss: claims.iss, sub: claims.sub, email: claims.email, emailVerified: claims.email_verified },
});
}
// A guest who is ALREADY logged in wants to link this provider to their account:
if (session.userId && session.userId !== user.id) {
if (/* gap 2 */) return badRequest("Verify your email with the provider first");
await linkIdentity(session.userId, claims.iss, claims.sub);
}Design "Add to Google Calendar"
Design PracticeMaya wants a button on each booking: "Add to Google Calendar". Guests log in with Google, email or Apple. When a booking's dates change, the calendar event should update too, even if the guest isn't using the app.
Design it: which scope, when to ask for consent, which tokens you store and where, and what happens when the guest disconnects.
API keys or client credentials for partners?
Decision StretchAfter last episode's leak, channel-manager partners now have scoped API keys (their hotels only, rates:write, availability:read). The "short-lived" part of that fix is still open: today the keys never expire. Two options:
- A. Keep scoped API keys, add yearly rotation reminders.
- B. OAuth client credentials: each partner gets a client id + secret, exchanges them for 1-hour access tokens with scopes. ~3 weeks of work; 60 partners must change their integration over 3 months.
Write a short decision: "We chose X over Y because…, accepting the risk of…".
Explain it yourself
Explain to a junior developer what happens when a guest clicks “Sign in with Google” on StayHub, and why each of state, PKCE and the id_token checks exists.