OAuth 2.0 and PKCE: 'Sign in with…' Step by Step

October 16, 2026 · 10 min read

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

user
app
auth server
API
click "Sign in with Example"
▶

A user wants an app to access their data at another service — say, their calendar. The app must never see their password.

0 / 7

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

WhereRiskVerdict
localStorage / sessionStorageReadable by any script on the page — one XSS and they're goneAvoid for anything long-lived
In memory in a SPALost on reload; still readable by injected script while it runsOK for short-lived access tokens
HttpOnly cookie holding the tokenNot readable by scripts; sent automatically (mind CSRF)Reasonable
Server-side session (BFF)Browser holds only a session cookie; tokens never reach itMost robust for web apps
Mobile OS keychain / keystoreProtected by the OSRight 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:

  1. Verify the token. For JWT access tokens, check the signature against the authorization server's published keys, plus iss, exp and — critically — aud, which must name this API. For opaque tokens, ask the authorization server's introspection endpoint (and cache the answer briefly).
  2. Check the scopes against the operation: a token with calendar.read must not be able to create events.
  3. Authorize the user behind the token for the specific resource: sub 123 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, http vs https, and localhost vs 127.0.0.1 all count.
  • invalid_grant at the token endpoint — the code expired, was already used (a double-submitted callback), or the code_verifier doesn't match the challenge (the verifier was lost between redirects — check where you stored it).
  • State mismatch — the session that stored state didn't survive the round trip, often because the session cookie is SameSite=Strict and so isn't sent on the cross-site redirect back. Lax is 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 aud on 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.