When an app says "Connect your Google Calendar", it needs to read your calendar. The old way was to ask for your Google password — which gives the app everything, forever, until you change it. OAuth 2.0 replaces that with a token: limited to what you approved, short-lived, and revocable, issued without the app ever seeing your password.
The roles
- Resource owner: you.
- Client: the app that wants access.
- Authorization server: where you log in and approve
(
accounts.google.com, for example). - Resource server: the API holding your data (the Calendar API).
The authorization code flow, with PKCE
A user wants an app to access their data at another service — say, their calendar. The app must never see their password.
The pieces, and what each one stops
Redirects through the browser. You log in on the provider's own page, in your browser. The app never handles your credentials, and the provider can show you exactly what's being requested.
Scopes. scope=calendar.read asks for read access to calendars and
nothing else. The consent screen shows it, and the resulting token is
limited to it. Ask for the narrowest scopes that work. Where you need more later, ask
for it then — incremental authorization — rather than front-loading every
permission into the first consent screen, which users increasingly
decline.
state. A random value the app generates, stores, and checks when the
redirect comes back. Without it, an attacker can complete a flow with their
authorization code and get it into your session — a login CSRF.
The authorization code. It's short-lived (often a minute or less) and single-use. It travels through the browser, so it might leak, which is why it isn't the token itself. The app exchanges it for tokens in a back-channel request.
PKCE (Proof Key for Code Exchange, RFC 7636, pronounced "pixie"). Before
the flow starts, the app creates a random code_verifier and sends only its
SHA-256 hash, the code_challenge. To redeem the code it must present the
verifier. Someone who intercepted the code — another app on a phone that
registered the same redirect URL, or a leaked log — doesn't have the
verifier, so the code is worthless to them.
// base64url(bytes): standard base64 with + → -, / → _, and no = padding
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)))
const challenge = base64url(new Uint8Array(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
))
// /authorize?…&code_challenge=${challenge}&code_challenge_method=S256
// /token …&code_verifier=${verifier}
PKCE was designed for mobile and single-page apps, which can't keep a client secret: anything shipped in an app can be extracted. It's now recommended for every client, including servers that also have a secret. The OAuth 2.1 draft makes it mandatory.
The flow as HTTP requests
The animation shows the shape. Here are the actual messages, which is what you'll see in DevTools when you debug one.
1. The app redirects the browser to the authorization server:
GET /authorize?response_type=code
&client_id=calendar-app
&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback
&scope=openid%20calendar.read
&state=af0ifjsldkj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: auth.example
2. After login and consent, the browser is redirected back:
HTTP/1.1 302 Found
Location: https://app.example/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj
3. The app's backend exchanges the code — server to server, not through the browser:
POST /token HTTP/1.1
Host: auth.example
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback
&client_id=calendar-app
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
4. And gets tokens back:
{
"access_token": "eyJhbGciOi…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8xLOxBtZp8",
"id_token": "eyJhbGciOi…",
"scope": "openid calendar.read"
}
The redirect_uri appears twice on purpose: the server checks that the
one used to redeem the code matches the one the code was issued for, so a
code can't be redirected somewhere else and redeemed there.
A server-side implementation
For a web app with a backend, the backend runs the flow and the browser
never sees a token. With Express and openid-client, the moving parts
fit on one screen (the library's API varies between major versions; the
steps don't):
app.get('/login', async (req, res) => {
const verifier = client.randomPKCECodeVerifier()
const state = client.randomState()
req.session.oauth = { verifier, state } // server-side session
const url = client.buildAuthorizationUrl(config, {
redirect_uri: REDIRECT_URI,
scope: 'openid email calendar.read',
code_challenge: await client.calculatePKCECodeChallenge(verifier),
code_challenge_method: 'S256',
state,
})
res.redirect(url.href)
})
app.get('/callback', async (req, res) => {
const { verifier, state } = req.session.oauth ?? {}
const tokens = await client.authorizationCodeGrant(config, currentUrl(req), {
pkceCodeVerifier: verifier,
expectedState: state, // library checks it
})
req.session.regenerate(() => { // new session ID after login
req.session.userId = tokens.claims().sub
req.session.refreshToken = tokens.refresh_token
res.redirect('/')
})
})
Note session.regenerate after login: issuing a fresh session ID at the
moment of authentication prevents session fixation, where an attacker
plants a session ID before you log in.
Tokens
- Access token: sent to the API as
Authorization: Bearer …. Short-lived — minutes to an hour. Often a JWT, but it doesn't have to be; to the client it's an opaque string. - Refresh token: used to get new access tokens without involving you again. Long-lived, so store it carefully (server-side if possible), and expect it to be rotated: each use returns a new one and invalidates the old.
Revoking the app's access in your account settings kills its refresh token; the access token expires soon after.
Rotation and reuse detection
Rotation does more than limit a refresh token's lifetime; it turns theft into something the server can notice. Each refresh token belongs to a family started by one login. When a refresh token is used, the server issues a new one and marks the old one spent. If a spent token is ever presented again, two parties hold copies of the same token — the real client and an attacker — and the server can't tell which is which. So it revokes the whole family, forcing a fresh login. The legitimate user signs in again; the attacker's copy is dead.
Where to keep tokens
| Where | Risk | Verdict |
|---|---|---|
| localStorage / sessionStorage | Readable by any script on the page — one XSS and they're gone | Avoid for anything long-lived |
| In memory in a SPA | Lost on reload; still readable by injected script while it runs | OK for short-lived access tokens |
| HttpOnly cookie holding the token | Not readable by scripts; sent automatically (mind CSRF) | Reasonable |
| Server-side session (BFF) | Browser holds only a session cookie; tokens never reach it | Most robust for web apps |
| Mobile OS keychain / keystore | Protected by the OS | Right for native apps |
Validating an ID token
If you do handle tokens yourself, validation is a checklist, and a JWT library does most of it:
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://auth.example/.well-known/jwks.json'))
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://auth.example', // iss must match exactly
audience: 'calendar-app', // aud must be *your* client ID
}) // signature and exp checked too
if (payload.nonce !== req.session.nonce) throw new Error('nonce mismatch')
The audience check is the one people skip, and it matters: an ID token
issued to a different app by the same provider is validly signed. Without
checking aud, any app that can get a user to log in could replay that
user's ID token to yours.
The API's side: checking access tokens
So far this has been the client's view. The resource server — your API — has its own job on every request:
- Verify the token. For JWT access tokens, check the signature
against the authorization server's published keys, plus
iss,expand — critically —aud, which must name this API. For opaque tokens, ask the authorization server's introspection endpoint (and cache the answer briefly). - Check the scopes against the operation: a token with
calendar.readmust not be able to create events. - Authorize the user behind the token for the specific resource:
sub123 may read calendar 123's events, not calendar 456's. Scopes say what the app may do; your own rules still decide what the user may do.
app.post('/calendars/:id/events', requireToken({ audience: 'calendar-api' }), (req, res) => {
if (!req.token.scope.split(' ').includes('calendar.write')) return res.status(403).end()
if (!canWrite(req.token.sub, req.params.id)) return res.status(404).end()
// …
})
Returning 404 rather than 403 for a resource the user can't see avoids
confirming that it exists.
Debugging a failed flow
When "Sign in with…" breaks, the error is almost always in one of a few places:
redirect_uri_mismatch— the URI in the request doesn't exactly match a registered one. Trailing slashes,httpvshttps, andlocalhostvs127.0.0.1all count.invalid_grantat the token endpoint — the code expired, was already used (a double-submitted callback), or thecode_verifierdoesn't match the challenge (the verifier was lost between redirects — check where you stored it).- State mismatch — the session that stored
statedidn't survive the round trip, often because the session cookie isSameSite=Strictand so isn't sent on the cross-site redirect back.Laxis the right setting for this cookie. - Token rejected by the API — wrong audience, an expired token that wasn't refreshed, or a missing scope. Decode the token (it's often a JWT) and compare its claims with what the API expects.
Flows to avoid
- Implicit flow: returned the access token directly in the redirect URL, where it could leak through history and referrers. Deprecated — use the code flow with PKCE.
- Resource owner password flow: the app collects your password and exchanges it for a token, defeating the purpose. Deprecated.
- Client credentials is fine, but it's for machine-to-machine access with no user involved — a backend service calling another API as itself.
Other flows worth knowing
Device authorization flow covers devices without a usable browser — a smart TV, a CLI tool. The device shows a short code and a URL; you open the URL on your phone, enter the code and approve; meanwhile the device polls the token endpoint until approval arrives. It's what "go to example.com/activate and enter ABCD-EFGH" screens are doing, and it's how many command-line tools log in.
Mistakes that keep showing up
- Missing or unchecked
state— the login CSRF described above. - Loose redirect URI matching — wildcards or prefix matching let an attacker send codes to a page they control on your domain, or chain an open redirect. Register exact URIs.
- Accepting tokens meant for someone else — not checking
audon ID tokens, or accepting any validly signed access token at your API without checking its audience and scopes. - Using the access token as proof of identity in the client — it's for the API. Identity comes from the ID token, or from asking the provider's userinfo endpoint.
- Over-broad scopes — asking for full mailbox access to read one label. Users notice, and a leaked token does far more damage.
- Long-lived access tokens to avoid implementing refresh. Short access tokens plus rotating refresh tokens is the whole point of the design.
OAuth is authorization; OpenID Connect adds login
OAuth answers "may this app access my calendar?". It doesn't say who you are: an access token is for the API, not for the app to read your identity from.
OpenID Connect (OIDC) is a layer on top. Add scope=openid and the token
response also includes an ID token, a JWT meant for the client, with
claims like sub (a stable user ID), email and name. The client verifies
it — signature, iss, aud equal to its own client ID, exp, and a nonce
it sent in the request. "Sign in with Google" is OIDC.
In practice
Don't implement the protocol by hand. Use your provider's SDK or a certified
library (openid-client in Node, Auth.js for Next.js), register exact
redirect URLs (no wildcards), request minimal scopes, and keep tokens off
the browser where you can — a backend-for-frontend that holds tokens and
gives the browser an HttpOnly session cookie
is the most robust shape for web apps.
One more idea worth knowing as it spreads: sender-constrained tokens. A normal bearer token works for whoever holds it, so a leaked token is as good as a stolen key. With DPoP (or mutual-TLS-bound tokens), the token is bound to a key pair the client holds, and every API request carries a fresh proof signed with that key. A token copied out of a log or a compromised proxy is useless without the private key. It's extra machinery, and worth it for high-value APIs such as banking.