Lecture 10: OAuth 2.0 and OpenID Connect¶
"Sign in with Google" is something you've used dozens of times as a user; this lecture teaches you to build it. You'll learn OAuth 2.0 — the authorization framework behind every "Sign in with..." button — in enough depth to implement the Authorization Code flow with PKCE correctly, layer OpenID Connect on top for authentication, and avoid the implementation mistakes that turn a convenience feature into a security hole.
In This Lecture¶
- Understand OAuth 2.0 as an authorization framework and its four roles
- Implement the Authorization Code flow with PKCE, and know the other grant types
- Work with access tokens, refresh tokens, scopes, and user consent
- Understand OpenID Connect, the ID token, and social login
- Configure Passport.js strategies and avoid common OAuth implementation mistakes
OAuth 2.0: An Authorization Framework, Not a Login Protocol¶
A common misconception is that OAuth 2.0 is a login system. It isn't — it's an authorization framework: a standardized way for a user to grant one application limited access to their data held by another application, without handing over their password. When you click "Allow" on a screen that says "MyApp wants to access your Google Calendar," you're watching OAuth 2.0 at work: MyApp never sees your Google password, only a scoped, revocable token.
OAuth defines four distinct roles:
| Role | Description | Example |
|---|---|---|
| Resource Owner | The user who owns the data and can grant access to it | You |
| Client | The application requesting access on the resource owner's behalf | The third-party app ("MyApp") |
| Authorization Server | Issues tokens after authenticating the resource owner and obtaining consent | Google's OAuth server |
| Resource Server | Hosts the protected data and accepts access tokens to serve it | Google Calendar API |
Note
The authorization server and resource server are often operated by the same provider (Google runs both), but they are logically separate roles — some large systems genuinely run them as different services.
Key vocabulary you'll see throughout this lecture: a scope is a permission string
requested by the client (e.g. calendar.readonly); consent is the resource owner's
explicit approval of the requested scopes; an access token is the credential the
client uses to call the resource server; a refresh token is a longer-lived
credential used to obtain new access tokens without re-prompting the user.
The Authorization Code Flow with PKCE¶
The Authorization Code flow is the flow you should use for essentially every web and mobile application today. Instead of the client receiving an access token directly from the browser (which would expose it to anyone who can read the URL or browser history), the client first receives a short-lived, single-use authorization code, which it then exchanges for tokens through a secure, server-to-server request.
PKCE (Proof Key for Code Exchange, pronounced "pixy") is now recommended for all clients, not just mobile/SPA clients as originally designed — it closes a vulnerability where an attacker who intercepts the authorization code (e.g. via a malicious app registering the same custom URL scheme) could exchange it for tokens themselves. PKCE works by having the client prove it is the same party that started the flow.
sequenceDiagram
participant U as User (Browser)
participant C as Client App
participant A as Authorization Server
participant R as Resource Server
C->>C: Generate code_verifier (random string)
C->>C: code_challenge = SHA256(code_verifier)
C->>U: Redirect to Authorization Server<br/>with code_challenge, scope, state
U->>A: GET /authorize?code_challenge=...&scope=...
A->>U: Show login + consent screen
U->>A: Approve
A->>U: Redirect back with authorization code
U->>C: GET /callback?code=...&state=...
C->>A: POST /token<br/>code + code_verifier
A->>A: Verify SHA256(code_verifier) == code_challenge
A-->>C: access_token + refresh_token (+ id_token)
C->>R: GET /api/resource<br/>Authorization: Bearer access_token
R-->>C: Protected data
Step by step:
- The client generates a random
code_verifierand derives acode_challengefrom it (a SHA-256 hash, Base64URL-encoded). - The client redirects the browser to the authorization server's
/authorizeendpoint, including thecode_challenge, the requestedscope, aredirect_uri, and a randomstatevalue (to prevent CSRF on the callback). - The user authenticates with the authorization server and approves (or denies) the requested scopes on a consent screen.
- The authorization server redirects back to the client's
redirect_uriwith a short-lived authorization code and the samestatevalue. - The client verifies
statematches what it sent, then exchanges the code for tokens by POSTing to the token endpoint — including the originalcode_verifier. - The authorization server re-derives the challenge from the verifier and compares it to the one stored with the code; if they match, it issues an access token (and usually a refresh token).
const crypto = require("crypto");
function base64url(buffer) {
return buffer
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
// Step 1: generate PKCE values before redirecting the user
const codeVerifier = base64url(crypto.randomBytes(32));
const codeChallenge = base64url(
crypto.createHash("sha256").update(codeVerifier).digest()
);
req.session.codeVerifier = codeVerifier; // stash for the callback
const authUrl = new URL("https://provider.example.com/authorize");
authUrl.searchParams.set("client_id", process.env.OAUTH_CLIENT_ID);
authUrl.searchParams.set("redirect_uri", "https://myapp.com/callback");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "profile email");
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("state", req.session.oauthState);
res.redirect(authUrl.toString());
Warning
Always validate the state parameter on your callback route before doing anything
else. Skipping it opens a login CSRF hole, where an attacker tricks a victim
into completing an OAuth flow initiated by the attacker, potentially binding the
victim's session to the attacker's third-party account.
Other Grant Types (Overview)¶
- Client Credentials — used for machine-to-machine calls where there is no resource owner at all (a backend service authenticating as itself). No user interaction, no browser redirect.
- Refresh Token grant — exchanges a refresh token for a new access token once the original expires, without involving the user again.
- Implicit flow — an older flow that returned the access token directly in the URL fragment. It is now considered deprecated/insecure (tokens can leak via browser history, referrer headers, or logs) and should not be used in new applications; Authorization Code + PKCE replaces it even for single-page apps.
- Resource Owner Password Credentials (ROPC) — the client collects the user's username/password directly and trades them for a token. Also discouraged, since it defeats the entire point of OAuth (the client sees the password) — only acceptable for highly trusted first-party legacy migrations.
Access Tokens, Refresh Tokens, Scopes, and Consent¶
Scopes let the resource owner grant partial access rather than all-or-nothing.
Requesting calendar.readonly instead of full account access follows the principle
of least privilege — the client only gets what it actually needs, limiting the blast
radius if the client itself is later compromised.
The consent screen is the user-facing moment where scopes become concrete and reviewable ("MyApp wants to: view your email address, view your calendar"). Well-behaved clients request the minimum scopes needed for their actual functionality — requesting broad scopes "just in case" is both a poor user experience and a security anti-pattern, since it makes the client a more valuable target.
Access tokens are deliberately short-lived (minutes to an hour); refresh tokens are long-lived and must be stored with the same care as any other sensitive credential — server-side, never exposed to client-side JavaScript.
OpenID Connect and the ID Token¶
OAuth 2.0 tells you what a client is allowed to access — it was never designed to answer who the user is. That gap is what OpenID Connect (OIDC) fills: a thin identity layer built directly on top of OAuth 2.0. OIDC adds a third token, the ID token, alongside the access and refresh tokens.
The ID token is a JWT (see Lecture 9) containing verified claims about the
authenticated user — sub (a stable unique user ID), email, name, iss (issuer),
aud (audience/client ID), and exp. Unlike an access token, whose contents are
opaque to the client and meant only for the resource server, the ID token is meant to
be read and verified by the client itself to establish who just logged in.
const { OAuth2Client } = require("google-auth-library");
const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
async function verifyIdToken(idToken) {
const ticket = await client.verifyIdToken({
idToken,
audience: process.env.GOOGLE_CLIENT_ID, // must match your client ID
});
const payload = ticket.getPayload();
// payload.sub is the stable Google user ID — use this, not email, as your key
return payload;
}
Tip
Always key your local user records off the ID token's sub claim, not the email
address. Emails can change or be reused across providers; sub is a stable,
provider-scoped identifier.
This is exactly the mechanism behind social login — "Sign in with Google/GitHub/ Facebook" buttons. The third-party provider acts as the OpenID Connect identity provider (IdP); your application is the relying party, trusting the IdP's verified identity instead of managing its own passwords for those users.
Passport.js Strategies¶
Passport.js is the de facto standard authentication middleware for Express. It doesn't implement any auth scheme itself — instead, it provides a common interface for pluggable strategies, each of which implements one authentication method (local username/password, Google OAuth, GitHub OAuth, JWT, and hundreds more).
const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: "/auth/google/callback",
},
async (accessToken, refreshToken, profile, done) => {
// profile contains the verified identity from Google
let user = await User.findOne({ googleId: profile.id });
if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
});
}
return done(null, user); // attaches `user` to req.user
}
)
);
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
const user = await User.findById(id);
done(null, user);
});
app.get("/auth/google", passport.authenticate("google", { scope: ["profile", "email"] }));
app.get(
"/auth/google/callback",
passport.authenticate("google", { failureRedirect: "/login" }),
(req, res) => res.redirect("/dashboard")
);
Passport handles the redirect dance and token exchange internally for OAuth-style strategies, calling your verify callback with the resulting profile once authentication succeeds — you decide how to map that profile onto your own user model.
Common OAuth Implementation Mistakes¶
Skipping state validation
Without checking state on the callback, your app is vulnerable to login CSRF —
an attacker can trick a victim into linking the attacker's account to the victim's
session. Always generate a random state, store it server-side (or in a signed
cookie), and verify it matches on callback.
Trusting the redirect_uri without an exact-match allowlist
Authorization servers should only redirect to redirect_uri values that were
pre-registered exactly. If you (as the authorization server operator) allow
wildcard or loosely matched redirect URIs, an attacker can redirect authorization
codes to a server they control.
Storing tokens client-side without protection
Just like the JWTs from Lecture 9, OAuth access and refresh tokens should be kept
server-side or in httpOnly cookies — never in localStorage where any XSS
payload can steal them.
Confusing the access token with proof of identity
An access token proves the client may call an API on the user's behalf — it does
not prove who the user is, and its contents are not guaranteed to be readable
or meaningful to the client. Use the OIDC ID token (and verify its signature and
aud/iss claims) to establish identity, never the access token.
Requesting more scopes than you need
Broad scope requests increase your liability if the client is compromised, and they scare away privacy-conscious users on the consent screen. Request the minimum scopes your feature set actually requires.
Try It Yourself¶
- Register an OAuth application with GitHub (Settings → Developer settings → OAuth
Apps) and implement "Sign in with GitHub" in a small Express app using
passport-github2. Confirm you can seereq.userpopulated after a successful login. - Manually implement the Authorization Code + PKCE flow (without Passport) against a
test OAuth provider of your choice: generate
code_verifier/code_challenge, build the/authorizeredirect URL, handle the callback, and exchange the code for tokens with a POST request. Log each intermediate value to confirm you understand every step of the sequence diagram above.
Key Takeaways¶
- OAuth 2.0 is an authorization framework, not a login protocol — it governs delegated, scoped access to resources, involving four roles: resource owner, client, authorization server, resource server.
- The Authorization Code flow with PKCE is the recommended flow for essentially all web and mobile clients today; the Implicit flow is deprecated.
- Access tokens should be short-lived and scoped narrowly; refresh tokens are long-lived and must be stored and revoked carefully.
- OpenID Connect adds the ID token on top of OAuth 2.0, turning "authorization" into verifiable "authentication" — this is what powers social login.
- Always verify an ID token's signature,
iss, andaudbefore trusting it, and key your user records on the stablesubclaim. - Passport.js provides a common strategy interface for dozens of auth methods, letting you plug in Google, GitHub, JWT, or local strategies with minimal boilerplate.
- Always validate
stateon OAuth callbacks and never store OAuth tokens in places readable by client-side JavaScript.