So far the app authenticates users itself. Delegated access is the opposite problem: letting one service act on a user's behalf at another service ("Sign in with Google," or "let this app read your GitHub repos") without ever handing over the user's password to that other service.
Before OAuth, apps literally asked for your Gmail password so they could log in as you and scrape what they needed: total access, no revocation short of changing your password, and a third party storing your credentials. OAuth's design is a reaction to that.
OAuth 2.0
OAuth 2.0 is the framework for this. Its whole point is delegated authorization: granting an app limited access to your data somewhere else. Four roles:
- Resource owner: you, the user. The data is yours; only you can grant access to it.
- Client: the app that wants access. It never sees your password.
- Authorization server: where you log in and consent (Google's, GitHub's). The only party that ever handles your credentials.
- Resource server: the API holding your data. It accepts access tokens, not logins.
Concretely: a scheduling app (client) wants to read your (resource owner) calendar from
the Google Calendar API (resource server), so it sends you to
accounts.google.com (authorization server) to approve it.
What you approve there are scopes : named slices
of access like calendar.readonly or repo. Scopes are least privilege
made visible: the consent screen lists them, the access token is limited to them, and the
resource server rejects anything outside them.
The main flow is the Authorization Code flow:
- Client redirects you to the authorization server (login + consent screen)
- You approve the requested scopes (e.g. "read your repos")
- Authorization server redirects back with a short-lived authorization CODE
- Client exchanges the code (+ its secret, or a PKCE verifier for public clients) for an ACCESS TOKEN , server-to-server
- Client calls the resource server with the access token
The two-step dance (code first, then exchange it for a token on the back channel) exists so the access token never travels through the browser/URL, where it could be logged or leaked. The code is useless on its own; redeeming it requires the client's secret.
The access token is usually short-lived and paired with a refresh token the client redeems on the back channel when it expires. Revocation works because the authorization server keeps state: when you remove an app from Google's "third-party access" page, its refresh token dies there.
OIDC
Here's the catch people trip on: OAuth is authorization, not authentication. An access token proves an app was granted access; it doesn't reliably tell you who the user is. Apps that used raw OAuth as a login mechanism opened subtle security holes by assuming "has a token" meant "is this user."
OpenID Connect (OIDC) is a thin identity layer on top of OAuth 2.0 that adds real authentication. It introduces:
-
An ID token : a JWT containing identity claims (
sub,email,name), signed by the provider, meant specifically to tell the client who logged in. -
A standard
/userinfoendpoint and standard scopes (openid,profile,email).
"Sign in with Google" is OIDC, not bare OAuth, and it's your first taste of
single sign-on (SSO) : authenticate once at a provider
you already trust, and carry that proven identity into any app that trusts the provider. One critical point: the ID token must be
verified, not just decoded: check the signature against the provider's public
keys, and validate the iss, aud, and exp claims.
Accepting an unverified or mis-targeted ID token re-opens the same identity confusion
vulnerabilities that raw OAuth had. The mental split worth keeping:
| OAuth 2.0 | OIDC | |
|---|---|---|
| Answers | What may this app access? | Who is this user? |
| Concern | Authorization (delegated access) | Authentication (identity) |
| Gives you | Access token (for calling APIs) | ID token (a JWT identifying the user) |
| Relationship | The framework | A layer built on top of OAuth |
The tokens split the same way: the access token goes to APIs, the ID token stays with the client as proof of who logged in. Never accept an ID token as an API credential, and never treat possession of an access token as a login.
OIDC covers the consumer and modern-app use case. In enterprise environments, two older standards play the same SSO role. SAML (Security Assertion Markup Language) is the XML-based protocol used by corporate Identity Providers (Okta, AD FS, Azure AD): the user authenticates once at the IdP, which issues a signed XML assertion to the app — no password ever reaches the app. LDAP (Lightweight Directory Access Protocol) is the underlying directory protocol — most commonly Microsoft Active Directory — where user accounts, groups, and attributes live; the IdP typically reads from it to power SSO. The two complement each other: LDAP stores the identities, SAML carries them to web applications.
PKCE
The Authorization Code flow assumes the client can keep a secret. OAuth calls these confidential clients: server-side apps whose code and config live where users can't see them. A public client (a single-page app or a mobile app) can't keep anything: its code ships to the user's device, so any secret baked into it is visible. That breaks step 4's security, because an attacker who intercepts the authorization code could redeem it themselves.
PKCE (Proof Key for Code Exchange, "pixy") closes that gap without a stored secret:
import crypto from "node:crypto";
// Before redirecting, the client makes a one-time secret and sends only its hash
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
// 1. Authorize request includes: code_challenge=<challenge>&code_challenge_method=S256
// 2. Token exchange includes: code_verifier=<verifier>
// The server hashes the verifier and checks it matches the challenge it saw earlier.
An intercepted code is now worthless: redeeming it requires the original verifier,
which never left the legitimate client. Generated fresh for each flow, held in memory, gone
when the exchange completes. PKCE started as a mobile/SPA fix and is now
recommended for all OAuth clients.
OAuth challenge · runs in your browser
Add "Sign in with Foogle" to your backend
You are wiring "Sign in with Foogle" into DemoApp's backend. Foogle is a spoof OAuth provider
that behaves exactly like Google: it runs the login and consent screen and hands your server an authorization code.
The provider, your session store, and the frontend are all built. The frontend's
Sign in with Foogle button already links straight to Foogle's authorize page and
remembers a random state (hover the button to see the exact URL it sends the user
to). You write the one endpoint that does the real work: handleCallback in
oauth.js. The client id and the token/userinfo URLs are in
providerConfig; the secret arrives as ctx.clientSecret.
- Verify
state: reject the request (ctx.deny(...)) unlessctx.query.statematchesctx.expectedState(). Do this before touching the code — it is the CSRF check. - Exchange the code (back channel):
POSTtoproviderConfig.tokenUrlasapplication/x-www-form-urlencodedwithgrant_type=authorization_code,code,redirect_uri,client_id, andclient_secret. The JSON response is{ access_token, token_type, scope }. In productionclient_secretlives in a server env var and never reaches the browser. - Load the user (back channel):
GETproviderConfig.userInfoUrlwith anAuthorization: Bearer <access_token>header. The JSON response is{ sub, name, email }. - Sign them in:
ctx.logIn(user)thenctx.redirect("/").
Start the server, then in the demo app click Sign in with Foogle, approve at the
consent screen, and you should land back in DemoApp signed in as Michael Scott. The app replays
the full round trip so you can see which hops went through the browser (the code) and which were
server-to-server (the tokens). The page also checks that your callback rejects a forged
state. When both are green the challenge passes. Edit the code and restart the
server to try again.
oauth.ts