System Design Fundamentals

OAuth 2.0 & OpenID Connect

A

OAuth 2.0 & OpenID Connect

The Third-Party App Problem

Picture this: A user wants to connect their calendar app to their social media account so their friends can see when they’re available. The app asks for their username and password.

This is terrible. Here’s why:

  1. Full account access: The app now has unrestricted access to the user’s account. It can change their password, delete posts, access private messages — everything.
  2. No revocation without cost: To revoke access, the user must change their password, which invalidates access for all apps using that password, not just this one.
  3. Credential exposure: If the calendar app is compromised, the user’s password — and by extension, all their accounts — are exposed.
  4. Audit trail is messy: You can’t see which app made which changes.

OAuth 2.0 solves this. Instead of sharing the password, the user grants the app limited, revocable access to specific data. This is the protocol behind every “Sign in with Google,” “Sign in with GitHub,” and “Sign in with Facebook” button you see on the internet.

The security benefit is profound: the user retains control, the third-party app never sees the password, and access can be revoked instantly.

OAuth 2.0: An Authorization Framework

Critical distinction: OAuth 2.0 is an authorization framework, not an authentication protocol. It lets you say “this app can access your photos” but doesn’t inherently tell you “this person is John Smith.”

The Four Roles

  1. Resource Owner: The user. They own the data.
  2. Client: The third-party app that wants access.
  3. Authorization Server: The service that grants tokens (owned by the Resource Owner). Example: Google’s OAuth server.
  4. Resource Server: The API that holds the protected data. Often owned by the same organization as the Authorization Server.

Grant Types: How Access is Granted

Different scenarios require different flows:

Authorization Code Grant (web apps):

User wants to sign into SomeApp using Google

1. User clicks "Sign in with Google"
2. SomeApp redirects to Google's OAuth server
3. User logs in to Google (if not already)
4. Google asks: "SomeApp wants access to your profile and contacts. Allow?"
5. User clicks "Allow"
6. Google redirects back to SomeApp with an authorization code
7. SomeApp exchanges the code for an access token (backend-to-backend)
8. SomeApp can now call Google APIs on behalf of the user

Why the extra step of exchanging code for token? Because the authorization code is useless without a client secret (which SomeApp keeps secure on its backend). This prevents attackers from intercepting the redirect URL and using the code.

Authorization Code + PKCE (mobile/single-page apps):

Standard Authorization Code flow assumes the client can keep a secret (client_secret). Mobile apps and single-page apps running in browsers can’t — they’re compromised, the attacker can extract the secret from the code.

PKCE (Pronounced “pixie”) — Proof Key for Code Exchange — adds an extra layer:

1. Client generates a random "code_verifier" (43-128 characters)
2. Client generates "code_challenge" = SHA256(code_verifier)
3. Client sends code_challenge to Authorization Server
4. Authorization Server stores the code_challenge
5. User grants permission
6. Client receives authorization code
7. Client exchanges code + code_verifier for token
8. Authorization Server verifies: SHA256(code_verifier) == code_challenge
9. Only the original client knows the verifier, so only they can exchange the code

This is now the recommended flow for all OAuth 2.0 clients, even web apps.

Client Credentials Grant (machine-to-machine):

One service calls another service’s API. No user is involved.

Service A needs to call Service B's API

1. Service A has a client_id and client_secret
2. Service A calls: POST /token
   - client_id: "service-a"
   - client_secret: "super-secret-key"
   - grant_type: "client_credentials"
   - scope: "read:data write:data"
3. Authorization Server issues an access token
4. Service A uses the token to call Service B

Device Code Grant (smart TVs, CLIs, no browser):

For devices without a browser (smart TV, IoT device, or terminal-based CLI):

User wants to authorize a CLI tool to access their GitHub account

1. CLI calls: POST /device_authorization
   - client_id: "cli-tool"
2. GitHub returns:
   {
     "device_code": "ABC123",
     "user_code": "WXYZ",
     "verification_uri": "https://github.com/device"
   }
3. CLI tells user: "Go to github.com/device and enter code WXYZ"
4. User goes to website and enters code
5. CLI polls /token endpoint until user approves
6. CLI receives access token and can proceed

Scopes: Granular Permissions

Instead of all-or-nothing access, OAuth 2.0 uses scopes to grant specific permissions:

scope: "read:profile write:posts read:messages"

The user sees exactly what the app is requesting. “This app wants to read your profile, create posts, and read your messages.” The user can grant or deny.

Scope design is critical:

  • Too broad: “Full account access” — users won’t grant it
  • Too granular: 50+ scopes — confusing UI
  • Goldilocks: 5-10 scopes that map to actual features

Access Tokens vs Refresh Tokens

Access Token:

  • Short-lived (15 minutes, 1 hour)
  • Used to call APIs
  • If leaked, attacker has limited window to exploit
  • Example: JWT or opaque string

Refresh Token:

  • Long-lived (7 days, 30 days, 90 days)
  • Stored securely on client (HttpOnly cookie, not localStorage)
  • Used to get new access tokens when they expire
  • If leaked, attacker can get a new access token

Best practice: Keep access tokens short-lived and refresh tokens long-lived, and rotate refresh tokens on each use (issue a new refresh token with each access token).

OpenID Connect: Adding Authentication

OAuth 2.0 solves authorization but not authentication. It lets an app say “this user gave me access to their photos” but doesn’t prove “this person is Alice.” That’s what OpenID Connect (OIDC) does.

OpenID Connect = OAuth 2.0 + Authentication

OIDC adds one new artifact: the ID Token.

OAuth 2.0 flow with OIDC:

1. User clicks "Sign in with Google"
2. App redirects to Google with scope: "openid profile email"
3. User logs in and grants permission
4. Google returns:
   {
     "access_token": "...", (for calling APIs)
     "id_token": "...",     (JWT with user identity)
     "refresh_token": "..."
   }
5. App decodes the ID token and extracts user identity:
   {
     "iss": "https://accounts.google.com",
     "sub": "110169547926049217646",
     "aud": "my-app-client-id",
     "iat": 1516239022,
     "exp": 1516242622,
     "name": "Alice",
     "email": "[email protected]",
     "picture": "..."
   }
6. App creates a session for Alice

The UserInfo endpoint is another OIDC feature. After getting an access token, the app can call:

GET /userinfo HTTP/1.1
Authorization: Bearer {access_token}

Response:
{
  "sub": "110169547926049217646",
  "name": "Alice",
  "email": "[email protected]",
  "email_verified": true,
  "picture": "..."
}

OAuth 2.0 vs OIDC: Clear Difference

AspectOAuth 2.0OIDC
PurposeAuthorization (access to data)Authentication (proving identity)
Key artifactAccess tokenID token (JWT with claims)
Use case”App can read your photos""Prove you are Alice”
Answer to”What can you do?""Who are you?”

The Authorization Code Flow: Step by Step

Here’s what happens when you click “Sign in with Google”:

sequenceDiagram
    participant User as User's Browser
    participant App as Your App<br/>(Backend)
    participant AuthServer as Google<br/>(Auth Server)

    User->>App: 1. Click "Sign in with Google"
    App->>User: 2. Redirect to Google auth endpoint
    Note over User: /authorize?client_id=...&scope=...&state=...&redirect_uri=...

    User->>AuthServer: 3. Browser follows redirect
    AuthServer->>User: 4. Google login page + consent screen

    User->>AuthServer: 5. Enter credentials, click "Allow"
    AuthServer->>User: 6. Redirect to your app with authorization code
    Note over User: /callback?code=ABC123&state=...

    User->>App: 7. Browser follows redirect
    App->>AuthServer: 8. Backend-to-backend: exchange code for token
    Note over App: POST /token<br/>client_id=..., client_secret=..., code=ABC123

    AuthServer->>App: 9. Return access_token and id_token
    App->>User: 10. Create session, redirect to app
    User->>User: 11. Logged in!

Pro tip: Notice the state parameter? That’s for CSRF protection. Your app generates a random state, passes it to Google, and verifies Google sends it back unchanged. This proves the redirect actually came from Google and not an attacker.

OIDC Discovery: The .well-known Endpoint

Every OIDC provider publishes a discovery document at:

GET https://accounts.google.com/.well-known/openid-configuration

This tells clients (your app) where to find all the endpoints:

{
  "issuer": "https://accounts.google.com",
  "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
  "token_endpoint": "https://oauth2.googleapis.com/token",
  "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
  "jwks_uri": "https://www.googleapis.com/oauth2/v1/certs",
  "scopes_supported": ["openid", "email", "profile", ...],
  "response_types_supported": ["code", "token", "id_token", ...],
  "grant_types_supported": ["authorization_code", "refresh_token", ...]
}

Notice jwks_uri? That’s the JSON Web Key Set endpoint where you download the public keys used to verify ID tokens. More on that in the next section.

Common OAuth Vulnerabilities

1. CSRF Attack (prevented by state parameter)

Attacker tricks user into clicking:
https://app.com/callback?code=attacker-controlled-code

If app doesn't verify the state parameter, it might accept this code and log the user in
as the attacker's account, giving the attacker access to the user's account on the app.

Protection: Always verify state parameter matches what you sent.

2. Redirect URI Manipulation

Attacker registers their own server: evil.com
Attacker crafts malicious OAuth URL:
https://google.com/authorize?client_id=...&redirect_uri=https://evil.com

If Google doesn't validate redirect_uri against registered URIs, the authorization code
goes to evil.com instead of the legitimate app.

Protection: Authorization servers MUST validate redirect_uri against a whitelist
of registered URIs. Apps MUST use exact string matching (not substring matching).

3. Token Leakage via Referrer Header

User logs in via OAuth, gets token in the redirect: /callback?code=ABC123
User clicks a link to an external site
Browser sends Referrer header to the external site: Referrer: https://app.com/callback?code=ABC123

The external site now has the user's authorization code.

Protection: Use POST instead of GET when possible. Use Authorization header instead
of query parameters. Set Referrer-Policy: strict-origin-when-cross-origin.

4. Token Storage on Frontend

Bad: Storing access token in localStorage or sessionStorage
localStorage is vulnerable to XSS attacks. If malicious JavaScript runs on the page,
it can steal the token.

Good: Store in HttpOnly, Secure, SameSite cookies. JavaScript can't access them,
and they're only sent over HTTPS to the same site.

Better: Store in memory. When the page refreshes, re-authenticate. (Yes, it's
slightly more friction, but more secure.)

SSO Across Multiple Applications

Let’s say you have three applications: email.mycompany.com, chat.mycompany.com, drive.mycompany.com.

Without SSO, users log in to each app separately. With OIDC-based SSO:

1. User logs in to email.mycompany.com via OIDC
   - App redirects to identity provider (Keycloak, Auth0, etc.)
   - User authenticates and grants permission
   - Identity provider sets a session cookie on *.mycompany.com
   - App receives ID token and creates a local session

2. User navigates to chat.mycompany.com
   - Chat app checks if user is logged in (not yet)
   - Chat app redirects to identity provider for OIDC flow
   - Identity provider sees the session cookie from step 1
   - Identity provider auto-approves (user already consented)
   - Chat app receives ID token and creates a local session
   - User is logged in without re-entering credentials

3. User logs out from email.mycompany.com
   - Email app calls identity provider's logout endpoint
   - Identity provider clears the session cookie
   - User navigates to chat.mycompany.com
   - Chat app checks, no longer has a valid session
   - User is logged out everywhere

The key: the identity provider maintains a single session cookie across all apps.

Trade-Offs: Self-Hosted vs Managed Identity Providers

Self-Hosted (Keycloak, Hydra, Dex):

  • Pros: Full control; no vendor lock-in; keep user data in-house
  • Cons: Operational burden (deployment, updates, security patches, backups); you must secure the auth system (one breach compromises everything)

Managed (Auth0, Cognito, Okta):

  • Pros: Zero operational burden; vendor handles security, scaling, reliability; integrations with many apps
  • Cons: Vendor lock-in; you trust a third party with your user data; cost scales with users

Hybrid: Some companies use a managed provider for initial auth but sync identities to an internal system for compliance/data residency reasons.

Key Takeaways

  1. OAuth 2.0 is for authorization, OIDC is for authentication. OAuth lets you access resources; OIDC proves who you are. Most “Sign in with” buttons use OIDC (OAuth + ID token).

  2. Use PKCE for all clients. Even web apps should use Authorization Code + PKCE. It’s the secure default.

  3. Scopes matter. Design them to be meaningful to users, not overly broad or granular. Users should understand what they’re granting.

  4. Short-lived access tokens, long-lived refresh tokens. Access tokens last 15 minutes to 1 hour. Refresh tokens last 7-90 days. Rotate refresh tokens on use.

  5. Protect the redirect URI. It’s the most valuable real estate in OAuth. Attackers will try to manipulate it. Always validate against a whitelist.

  6. Validate the state parameter. Simple but critical CSRF protection. Never skip it.

  7. PKCE isn’t just for mobile. It’s good security practice for all OAuth clients. Use it everywhere.

Practice Scenarios

Scenario 1: Token Leakage A user logs in via OAuth using the Authorization Code flow. The authorization code is returned as a query parameter: /callback?code=ABC123. The user sees a console error on the page and clicks a link to Stack Overflow in the same browser tab. Does Stack Overflow receive the authorization code? (Hint: Check Referrer header behavior. Consider Referrer-Policy.)

Scenario 2: Scope Creep Your app initially requests openid profile scope. Six months later, feature requests pile up and the app needs to send emails on behalf of users, read calendar data, and access files. Do you request one mega-scope like full_access, or many granular scopes? What does the consent screen look like? How would a user feel?

Connection to Next Section

OAuth 2.0 and OIDC frequently use JSON Web Tokens (JWTs) as ID tokens and access tokens. In the next section, we’ll deep dive into JWTs: how they work, how to validate them, what attacks they’re vulnerable to, and the critical gotchas that have caused real security breaches.