Chapter 16: OAuth and OpenID Connect — Authentication Integration With External Services
What you'll learn in this chapter
- Distinguish OAuth 2.0's four roles (Resource Owner / Client / Authorization Server / Resource Server)
- Understand that Authorization Code Flow + PKCE is the modern standard
- Explain the structure of an ID Token (JWT) — header.payload.signature — and its required claims
- Design a database for social login
- Understand the background behind Implicit Flow being deprecated
You should understand Chapter 15: Designing an Authentication and Authorization System. OAuth is a complex specification, and this chapter focuses narrowly on the entry point to implementing it.
What Is OAuth 2.0?
OAuth 2.0 is an authorization protocol for "letting another service use your service's data." Common scenarios:
- "Log in with your Google account" (Google authenticates → your own site receives that identity)
- "Post via the Twitter API from your own app" (your app accesses a user's Twitter data on their behalf)
- "Send GitHub notifications to Slack" (Slack has permission to receive GitHub's webhooks)
- OAuth 2.0 is a protocol for authorization (does this person have the right to access X?)
- OpenID Connect (OIDC) is an extension built on top of OAuth 2.0 for authentication (who is this person?)
"Log in with Google" is actually OpenID Connect (= authorization via OAuth 2.0, plus identity verification via an ID Token). The two are easy to conflate, but they address different problems.
OAuth 2.0's Four Roles
| Role | Example |
|---|---|
| Resource Owner (the user) | You (the owner of the Google account) |
| Client (the app) | A third-party app you use (a photo-editing app, for example) |
| Authorization Server | Google's login server |
| Resource Server | The Google Drive API |
"The Client accesses the Resource Owner's data from the Resource Server, having gone through the Authorization Server's authorization" is the basic structure of OAuth.
Authorization Code Flow + PKCE (The Modern Standard)
OAuth 2.0 has several "flows" (authorization procedures), but as of 2026, the standard is Authorization Code Flow + PKCE (RFC 9700 / the OAuth 2.1 draft).
PKCE Is Mandatory for a Public Client (and Recommended Even for Confidential)
The OAuth security advisory RFC 9700 (2025-01) makes PKCE (Proof Key for Code Exchange) mandatory (MUST) for a Public Client (an SPA or mobile app). It's recommended (RECOMMENDED) for a Confidential Client (server-side) too, though OpenID Connect's confidential clients can substitute the nonce parameter. Furthermore, the OAuth 2.1 draft builds PKCE directly into the authorization code grant procedure itself, making it a de facto standard feature. PKCE was originally introduced for Public Clients in RFC 7636 (2015).
With PKCE, even if a malicious intermediary steals the authorization code, they can't exchange it for a token without knowing the code_verifier.
Don't Use Implicit Flow
Implicit Flow, once used for SPAs, was deprecated by RFC 9700 (2025-01) and removed by the OAuth 2.1 draft. Older tutorials or explanations from 2018 or earlier sometimes introduce Implicit Flow, but the correct approach today is to use Authorization Code Flow + PKCE, even for an SPA.
Reason: Implicit Flow passes the token via the URL fragment, which carries a high risk of it leaking through browser history, referrer headers, or a script on the redirect destination.
OpenID Connect's ID Token (JWT)
OpenID Connect issues an ID Token representing "who this person is." The ID Token itself is a standard format called JWT (JSON Web Token, RFC 7519) — a string made of three parts separated by ..
eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJzdWIiOiIxMTAxNjkxNTM1NTkzMjEzOTI3MzMiLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJleHAiOjE3MzAwMDAwMDAsImlhdCI6MTcyOTk5NjQwMH0.xxxxxxxxxxxxxxxxxx
└────── Header ──────┘ └─────────────── Payload ───────────────────────┘ └─ Signature ─┘
The Three Parts
| Part | Content |
|---|---|
| Header | The algorithm (alg) and key ID (kid) |
| Payload | Claims (information about the user) |
| Signature | A cryptographic signature over the header + payload (e.g., RS256) |
Required Claims (Defined by OpenID Connect Core 1.0)
| Claim | Meaning |
|---|---|
iss (issuer) | The issuer (e.g., https://accounts.google.com) |
sub (subject) | The user's unique ID (unique within the issuer) |
aud (audience) | The intended recipient of this token (the client ID) |
exp (expiration) | The expiration time (Unix time) |
iat (issued at) | The time it was issued |
Verification Steps (Client Side)
- Verify the signature: verify the
Signaturewith the issuer's public key (fetched from/.well-known/openid-configuration) - Is
issthe expected issuer? - Is
audyour own client ID? - Is
expin the future relative to the current time? - Does it match the
nonce(a one-time random value the client sent with the authorization request, to prevent replay attacks)?
Only once all of these check out do you trust sub and the other payload fields to identify the user.
DB Design for Social Login
This is a DB design for storing integration with an external IdP (Identity Provider), like "log in with Google."
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
display_name VARCHAR(100) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_oauth_providers (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(30) NOT NULL, -- 'google' / 'github' / 'apple'
provider_user_id VARCHAR(255) NOT NULL, -- the sub (user ID) within the IdP
access_token TEXT, -- encryption recommended
refresh_token TEXT, -- encryption recommended
token_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMPTZ,
PRIMARY KEY (provider, provider_user_id), -- one row per user, per IdP
UNIQUE (user_id, provider) -- one user has at most one link per IdP
);
CREATE INDEX idx_oauth_user ON user_oauth_providers(user_id);
Key points:
- Making the primary key the composite
(provider, provider_user_id)prevents "creating multiple users tied to the same Google account" - Encrypt
access_token/refresh_tokenat the application layer before storing them (manage the key separately, e.g. with a KMS) - A design where one user can link multiple IdPs (Google + GitHub)
Storing and Refreshing Tokens
There are two kinds of OAuth 2.0 tokens.
| Token | Purpose | Lifetime |
|---|---|---|
| Access token | Sent with every API call | Short-lived (typically an hour, varies by IdP) |
| Refresh token | Used to get a new access token once it expires | Long-lived (days to months, varies by IdP) |
Expiration isn't defined by the OAuth 2.0 spec (RFC 6749) — it's up to the IdP's configuration. For example:
- Google: access token 1 hour / refresh token doesn't expire (as long as it's being used)
- Facebook: access token 1-2 hours / long-lived access token 60 days
- Microsoft: access token 1 hour / refresh token 90 days
There's no fixed "industry standard is X hours" value, so check the IdP's documentation.
Refreshing With a Refresh Token
-- Application-layer logic (Node.js pseudocode)
async function getValidAccessToken(userId, provider) {
const row = await db.query(
'SELECT access_token, refresh_token, token_expires_at FROM user_oauth_providers WHERE user_id = $1 AND provider = $2',
[userId, provider]
);
// Refresh if it expires within 5 minutes
if (row.token_expires_at <= new Date(Date.now() + 5 * 60 * 1000)) {
const newTokens = await refreshOauthToken(provider, row.refresh_token);
await db.query(
'UPDATE user_oauth_providers SET access_token = $1, refresh_token = $2, token_expires_at = $3 WHERE user_id = $4 AND provider = $5',
[encrypt(newTokens.access_token), encrypt(newTokens.refresh_token), newTokens.expires_at, userId, provider]
);
return newTokens.access_token;
}
return decrypt(row.access_token);
}
Exercises
Choose the best OAuth flow for each of the following scenarios.
- Google login on a browser-based SPA (a React app)
- Linking with Spotify from a native iOS app
- Regularly calling an external service server-to-server (backend ← API)
- An old system using Implicit Flow
Sample answer
| # | Flow | Reason |
|---|---|---|
| 1. Google login from an SPA | Authorization Code + PKCE | An SPA is a Public Client; PKCE protects against authorization-code theft |
| 2. A native iOS app | Authorization Code + PKCE | A native app is a Public Client too. Using an SDK is the practical approach |
| 3. Server-to-server integration | Client Credentials Flow | This is the server's own access, not on behalf of a user; Authorization Code isn't used |
| 4. Old Implicit Flow | Migrate to Authorization Code + PKCE | Implicit Flow is deprecated by RFC 9700; plan a migration |
Additional note: Client Credentials Flow is for server-to-server integration with no user involved. Example: a backend posting a message via the Slack API.
Common mistakes:
- "If you have a JWT, you don't need an authorization server": JWT is just a token format; the basic requirement that an authorization server must issue it doesn't change
- "Implicit Flow is fine for an SPA": outdated information. Even for an SPA, Authorization Code + PKCE is the right answer as of 2026
- "Client Credentials doesn't need PKCE": Client Credentials Flow has no authorization code involved, so PKCE itself doesn't apply (= there's no authorization-code-theft risk to protect against)
You're the Client, and you've received an ID Token issued by Google. Describe the verification steps in 5 steps.
Sample answer
- Split the token into three parts: split on
.intoheader.payload.signature - Fetch the public key: fetch
jwks_urifromhttps://accounts.google.com/.well-known/openid-configuration, and pull the public key matchingheader.kidfrom that JWKS - Verify the signature: verify the signature over
header.payloadwith the public key, usingheader.alg(typicallyRS256) - Verify the claims:
- Is
isshttps://accounts.google.com? - Is
audyour own client ID? - Is
expin the future relative to now (exp > now)? - Is
iatclose to the current time (allowing a discrepancy of aroundabs(iat - now) < 5min)? - (If you included a
noncewhen sending the request) does thenoncematch?
- Is
- If OK, identify the user: treat
payload.subas the "unique ID within Google," match it againstuser_oauth_providers.provider_user_id, and identify the corresponding user in your own system
An implementation library (Node.js):
import * as jose from 'jose';
const JWKS = jose.createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs'));
const { payload } = await jose.jwtVerify(idToken, JWKS, {
issuer: 'https://accounts.google.com',
audience: process.env.GOOGLE_CLIENT_ID,
});
// payload.sub is the user's ID within Google
const user = await findUserByOauth('google', payload.sub);
Common mistakes:
- Skipping signature verification: just base64-decoding the payload and using it is critical. An attacker could craft an arbitrary payload
- Allowing
alg: none: a vulnerability in older JWT libraries. Accepting an unsigned JWT means it can be tampered with freely - Hardcoding the public key: Google rotates its keys periodically. Fetch it dynamically from the JWKS
Summary
Here's a recap of what this chapter covered.
- OAuth 2.0 is a protocol for authorization (access rights); OIDC is an authentication (identity verification) extension built on top of OAuth 2.0
- The modern standard is Authorization Code Flow + PKCE (mandatory for a public client per RFC 9700, a standard feature in OAuth 2.1)
- Implicit Flow is deprecated and removed (watch out for older tutorials)
- The ID Token uses the JWT (RFC 7519) format; the required claims are
iss/sub/aud/exp/iat - JWT verification follows the order "signature → claims → identify the user"; use a JWT library
- For social login, use
(provider, provider_user_id)as the primary key, and store tokens encrypted
That wraps up Part 3 (Applied) of this guide. Starting with the next chapter, we move into the practice section and work through a comprehensive example: requirements → ER diagram → design, end to end.