OAuth 2.0 and OpenID Connect — The Protocols Behind 'Sign in with Google'
In 2006, Twitter needed a way to let third-party apps post on a user’s behalf — without the user giving their Twitter password to the third-party. The solution they built was the direct ancestor of OAuth. By 2010, OAuth 1.0 was a standard. By 2012, OAuth 2.0 (RFC 6749) was published, addressing OAuth 1.0’s complexity and lack of mobile support.
Today, every API you call that requires permission — from Google to Netflix to your bank — uses OAuth 2.0. It is not a single protocol but a framework: a set of flows for delegating access, a token format, and an extensible consent model. OpenID Connect (OIDC) sits on top as a thin identity layer.
This post covers the full picture — flows, tokens, endpoints, consent, claims, delegation patterns, and where the standard is heading.
Why OAuth Over SAML
SAML 2.0 solved enterprise browser SSO. OAuth 2.0 solved a different and broader set of problems:
| Need | SAML 2.0 | OAuth 2.0 |
|---|---|---|
| Browser SSO for enterprise apps | ✅ Designed for this | Works but non-native |
| REST API access control | ❌ XML assertions don’t fit | ✅ Core use case |
| Mobile app authentication | ❌ Requires browser embeds | ✅ PKCE flow designed for native apps |
| Machine-to-machine | ❌ Not supported | ✅ client_credentials grant |
| Delegated access (act on user’s behalf) | ❌ Limited | ✅ Core design intent |
| Refresh tokens | ❌ Not standardised | ✅ Native concept |
| Fine-grained API scopes | ❌ Attribute-based only | ✅ OAuth scopes |
| Token format | Large XML | Compact JWT (~500 bytes) |
OAuth 2.0 was designed for API-first, mobile-first, delegated-access scenarios. Use SAML for legacy enterprise SSO; use OAuth 2.0 for everything built after 2015.
The Four Roles in OAuth 2.0
flowchart TB
RO["👤 Resource Owner<br/>(The user who owns the data)"]
Client["📱 Client<br/>(App requesting access<br/>Web, Mobile, SPA, CLI)"]
AS["🏛️ Authorization Server (AS)<br/>Issues tokens after consent<br/>Examples: Okta, Entra ID,<br/>Google, Auth0"]
RS["🖥️ Resource Server (RS)<br/>Hosts the protected resource<br/>Examples: Gmail API,<br/>Salesforce API, your own API"]
RO -->|Grants permission| AS
Client -->|Requests access| AS
AS -->|Issues token| Client
Client -->|Presents token| RS
RS -->|Returns resource| Client
style AS fill:#1e3a5f,stroke:#3b82f6,color:#fff
style RS fill:#1e4620,stroke:#22c55e,color:#fff
style Client fill:#713f12,stroke:#f59e0b,color:#fff
style RO fill:#4a1a1a,stroke:#ef4444,color:#fff
Scopes are the mechanism for limiting what a token allows. When a client requests access, it specifies scopes: openid profile email calendar.read. The AS presents these to the user in a consent screen. The issued token carries only the approved scopes. The Resource Server enforces them on every API call.
The Authorization Code Flow + PKCE
This is the flow you interact with every time you click “Sign in with Google.” It is the standard for any application where a user is present — web apps, mobile apps, SPAs.
PKCE (Proof Key for Code Exchange) was added to prevent authorization code interception attacks on mobile/public clients. It is now mandatory in OAuth 2.1 for all clients.
sequenceDiagram
participant User
participant App as Client App<br/>(Mobile / Web / SPA)
participant AS as Authorization Server<br/>(Google, Okta, Entra)
participant API as Resource Server<br/>(Gmail API, your API)
Note over App: Generate code_verifier (random 43-128 chars)<br/>code_challenge = BASE64URL(SHA256(code_verifier))
App->>AS: GET /authorize<br/>?response_type=code<br/>&client_id=abc<br/>&redirect_uri=https://app/callback<br/>&scope=openid profile email<br/>&state=xyz<br/>&code_challenge=...&code_challenge_method=S256
AS->>User: Show login + consent screen<br/>"App wants to read your profile and email"
User->>AS: Approve
AS-->>App: Redirect to redirect_uri<br/>?code=AUTH_CODE&state=xyz
Note over App: Verify state matches (CSRF protection)
App->>AS: POST /token<br/>code=AUTH_CODE<br/>&code_verifier=ORIGINAL_VERIFIER<br/>&redirect_uri=...&client_id=...
Note over AS: SHA256(code_verifier) must match stored code_challenge
AS-->>App: JSON response:<br/>access_token, id_token,<br/>refresh_token, expires_in
App->>API: GET /userinfo<br/>Authorization: Bearer <access_token>
API-->>App: Protected resource
Why PKCE? On mobile, the redirect_uri is a custom scheme (myapp://callback). Any app on the device can register the same scheme and intercept the authorization code. PKCE ensures that even if someone intercepts the code, they cannot exchange it for a token without the original code_verifier — which only the legitimate app holds.
The state parameter is a random nonce the client generates and verifies on return. It protects against CSRF attacks — an attacker cannot initiate an authorization flow on behalf of the user and then have the user accidentally approve it.
Token Response — What You Actually Receive
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0xIn0...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0xIn0...",
"scope": "openid profile email"
}
| Field | Purpose | Who uses it |
|---|---|---|
access_token |
Proves the holder has the granted scopes; passed to Resource Server | Client → API |
expires_in |
Seconds until access_token expires (typically 3,600 = 1 hour) | Client (schedules refresh) |
refresh_token |
Long-lived token used to get a new access_token without re-authenticating | Client → Token endpoint only |
id_token |
OIDC addition — contains identity claims about the user; for the client to read | Client only |
scope |
What was actually granted (may differ from what was requested) | Client verifies |
Client Credentials Flow — Machine to Machine
When no user is present — a background job, a microservice, a scheduled task — the client authenticates with its own credentials directly.
sequenceDiagram
participant Svc as Service / Background Job
participant AS as Authorization Server
participant API as Resource Server (API)
Svc->>AS: POST /token<br/>grant_type=client_credentials<br/>&client_id=svc-invoice-etl<br/>&client_secret=<secret><br/>&scope=invoices:read invoices:write
AS->>AS: Verify client credentials<br/>No user consent needed
AS-->>Svc: access_token (no refresh_token,<br/>no id_token — no user identity)
Svc->>API: GET /invoices<br/>Authorization: Bearer <access_token>
API-->>Svc: Invoice data
No user is involved. No consent screen. The client IS the resource owner. Refresh tokens are not issued — the client simply re-authenticates when the access token expires. For higher security, replace client_secret with client certificate authentication (mTLS) or private_key_jwt.
Device Authorization Flow — TVs, CLIs, Smart Devices
When an input-constrained device (smart TV, CLI tool, IoT device) cannot open a browser or receive a redirect, the Device Authorization Grant is used.
sequenceDiagram
participant TV as Smart TV / CLI
participant AS as Authorization Server
participant Phone as User's Phone / Browser
TV->>AS: POST /device_authorization<br/>client_id=netflix-tv&scope=profile streaming
AS-->>TV: device_code, user_code (e.g. BDFH-XTRQ),<br/>verification_uri (e.g. netflix.com/activate),<br/>interval=5, expires_in=900
TV->>TV: Display: "Go to netflix.com/activate<br/>Enter code: BDFH-XTRQ"
Phone->>AS: User navigates to netflix.com/activate<br/>Enters BDFH-XTRQ + approves
loop Poll every 5 seconds
TV->>AS: POST /token<br/>grant_type=device_code<br/>&device_code=...
AS-->>TV: authorization_pending (keep polling)
end
AS-->>TV: access_token + refresh_token (once user approves)
TV->>TV: Authenticated — start streaming
Deprecated Flows — Why They Were Removed
Implicit Flow: Issued access tokens directly in the URL fragment after authorization (#access_token=...). Tokens were visible in browser history, referrer headers, and server logs. Browsers’ inability to keep secrets made this insecure. Removed in OAuth 2.1.
Resource Owner Password Credentials (ROPC): The client collected the user’s username and password and sent them directly to the AS. Violated the principle that clients should never see user credentials. Only ever appropriate for legacy migration scenarios. Deprecated in OAuth 2.1.
OpenID Connect — The Identity Layer OAuth Was Missing
OAuth 2.0 is an authorization framework — it answers “what can this client do?” It does not answer “who is the user?” That gap led to each AS inventing its own /userinfo endpoint with its own format.
OpenID Connect (OIDC) standardises the identity layer on top of OAuth 2.0. It adds:
- The ID Token — a JWT containing standard identity claims
- The /userinfo endpoint — for fetching additional claims
- Standard scopes (
openid,profile,email,address,phone) that map to standard claims - Discovery endpoint — for auto-configuration
OIDC is requested by including openid in the scope. If openid is absent, it is a pure OAuth flow (authorization only). If present, OIDC layer activates and the ID Token is issued.
flowchart TD
OAuth["OAuth 2.0<br/>Authorization Framework<br/>'What can this client do?'"]
OIDC["OpenID Connect<br/>Identity Layer on OAuth 2.0<br/>'Who is the user?'"]
OAuth --> OIDC
OIDC --> IDToken["ID Token (JWT)<br/>sub, name, email, iat, exp, aud, iss"]
OIDC --> UserInfo["/userinfo endpoint<br/>Additional profile claims"]
OIDC --> Discovery["/.well-known/openid-configuration<br/>Auto-discovery of endpoints"]
style OAuth fill:#1e3a5f,stroke:#3b82f6,color:#fff
style OIDC fill:#1e4620,stroke:#22c55e,color:#fff
Real-World OAuth in Action
Netflix: When you log in to Netflix on your TV (Device flow), PC (Authorization Code), or phone (Authorization Code + PKCE), each device gets its own access token and refresh token. Netflix’s Resource Servers validate the token’s scope to determine what content tier you can stream. Your subscription data lives on their Resource Server — protected by OAuth.
Shopping / Saved Cards: When you tap “Pay with Google Pay,” Google acts as an OAuth Resource Server. Your shopping app presents an access token with payments:charge scope. Google verifies the scope and charges the saved card. The merchant app never sees your card number.
Sign in with Google: This is the Authorization Code flow + OIDC. Your app requests openid profile email. Google authenticates you, presents a consent screen, and issues an ID Token containing your sub (Google’s unique user ID), name, and email. Your app creates a local account mapped to that sub. Next time you sign in, Google issues a new ID Token with the same sub — your app recognises you.
JWT — The Token Format
A JSON Web Token (JWT) is a compact, signed data structure used for both access tokens and ID tokens. It has three Base64URL-encoded parts separated by .:
HEADER.PAYLOAD.SIGNATURE
Decoded Header:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2026-05"
}
Decoded Payload (ID Token example):
{
"iss": "https://accounts.google.com",
"sub": "108543291847362019",
"aud": "client-id-of-your-app",
"iat": 1748668200,
"exp": 1748671800,
"email": "super.man@gmail.com",
"name": "super man",
"picture": "https://...",
"email_verified": true
}
Standard claims explained:
| Claim | Meaning |
|---|---|
iss |
Issuer — who issued this token |
sub |
Subject — the user’s unique ID at this IdP (immutable) |
aud |
Audience — which client/API this token is for |
iat |
Issued At — Unix timestamp |
exp |
Expiry — Unix timestamp; reject if past this |
jti |
JWT ID — unique ID, used for revocation lists |
The signature is computed as: RSA-SHA256(BASE64URL(header) + "." + BASE64URL(payload)) using the AS’s private key. The Resource Server validates with the AS’s public key — no round-trip to the AS needed for stateless verification.
Opaque Tokens vs JWT
| Type | Format | Validation | Use when |
|---|---|---|---|
| JWT | Self-contained, parseable | Local — verify signature with public key | Standard API access, OIDC |
| Opaque | Random string (reference token) | Introspection — call AS /introspect endpoint |
When you cannot trust clients with token contents; or for instant revocation |
Opaque tokens are better for revocation — the AS can invalidate them immediately. JWTs are valid until exp regardless of revocation (mitigated by short TTLs and key rotation).
Consent Management
flowchart TD
App["App requests:<br/>openid profile email calendar.read files.write"]
Consent["Authorization Server<br/>Consent Screen:<br/>📧 See your email address<br/>👤 See your basic profile info<br/>📅 View your calendar events<br/>🗂️ Manage your Drive files — Write access"]
User{User decision}
App --> Consent --> User
User -->|Approve all| Full["Full token issued<br/>all scopes granted"]
User -->|Partial approval| Partial["Partial token<br/>only approved scopes"]
User -->|Deny| Deny["error=access_denied<br/>returned to app"]
style Consent fill:#1e3a5f,stroke:#3b82f6,color:#fff
style Full fill:#1e4620,stroke:#22c55e,color:#fff
style Deny fill:#7f1d1d,stroke:#ef4444,color:#fff
Consent persistence: Most AS platforms remember consent decisions. Returning to the same app with the same scopes does not re-prompt the user. If the app requests new scopes, the consent screen appears again for only the new scopes.
Consent revocation: Users can revoke an app’s consent at any time through the AS’s account settings (e.g., myaccount.google.com/permissions). The AS marks the refresh token as revoked. The next time the app tries to refresh, it gets an error and must re-initiate the flow.
.well-known Endpoints — Auto-Discovery
OAuth 2.0 Authorization Server Metadata (RFC 8414) and OIDC Discovery publish all endpoint URLs and capabilities at a standard location:
GET https://accounts.google.com/.well-known/openid-configuration
Response (abbreviated):
{
"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/v3/certs",
"revocation_endpoint": "https://oauth2.googleapis.com/revoke",
"introspection_endpoint": "https://oauth2.googleapis.com/tokeninfo",
"scopes_supported": ["openid", "profile", "email", "..."],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"id_token_signing_alg_values_supported": ["RS256"]
}
JWKS — JSON Web Key Set: The jwks_uri points to the AS’s public keys used to sign tokens:
{
"keys": [
{
"kty": "RSA",
"kid": "key-2026-05",
"use": "sig",
"alg": "RS256",
"n": "<modulus>",
"e": "AQAB"
}
]
}
Resource Servers fetch the JWKS periodically (or on cache miss). When a JWT arrives, the RS reads the kid (key ID) from the JWT header, finds the matching public key in the JWKS, and verifies the signature locally — no call back to the AS per request.
Refresh Tokens and Token Lifecycle
flowchart TB
Login["Initial Login<br/>→ access_token (1 hour)<br/>→ refresh_token (30 days)"]
Use["Use access_token<br/>for API calls"]
Expire["access_token expires<br/>after 1 hour"]
Refresh["Client POSTs refresh_token<br/>to /token endpoint"]
New["New access_token issued<br/>Refresh token rotated<br/>(old one invalidated)"]
RevRefresh["Refresh token expires<br/>after 30 days<br/>or is revoked"]
ReLogin["User must re-authenticate"]
Login --> Use --> Expire --> Refresh --> New --> Use
New --> RevRefresh --> ReLogin
style Login fill:#1e3a5f,stroke:#3b82f6,color:#fff
style New fill:#1e4620,stroke:#22c55e,color:#fff
style ReLogin fill:#7f1d1d,stroke:#ef4444,color:#fff
Refresh token rotation: Modern AS platforms issue a new refresh token with every refresh request and immediately invalidate the previous one. If an old refresh token is used (indicating theft and reuse), the AS can invalidate the entire token family and force re-authentication.
Absolute vs sliding expiry: Refresh tokens have both a sliding expiry (extended on each use) and an absolute maximum lifetime. A refresh token used every day may live indefinitely within the sliding window, but the absolute maximum (often 90 days) forces periodic re-authentication regardless.
Customising Claims — Policy-Based Token Generation
Out-of-the-box, an access token carries standard claims. In practice, APIs need richer context — department, clearance level, subscription tier. AS platforms let you add custom claims via policy:
In Okta: Custom claims are added via Expression Language rules on the Authorization Server. A rule might say: user.department == "Finance" ? "finance-tier-1" : "standard" — injecting a custom access_tier claim based on the user’s directory attribute.
In Microsoft Entra ID: Optional claims are configured in the app registration manifest. Claims mapping policies allow mapping directory attributes to token claims. Entra also supports custom security attributes as claim sources.
Example — enriched access token payload:
{
"sub": "108543291847362019",
"iss": "https://acme.okta.com",
"aud": "api://trading-platform",
"scope": "portfolio:read trades:write",
"exp": 1748671800,
"department": "Equities",
"trader_tier": "institutional",
"risk_limit_usd": 5000000,
"clearance": "L3"
}
The trading platform’s API reads trader_tier and risk_limit_usd directly from the token — no database lookup needed for authorization decisions.
Token Delegation Patterns
OAuth 2.0 Token Exchange (RFC 8693)
Token Exchange allows a service to exchange one token for a different token — for a different audience, a different scope, or to impersonate a user.
sequenceDiagram
participant ServiceA
participant AS as Authorization Server
participant ServiceB
ServiceA->>AS: POST /token<br/>grant_type=urn:ietf:params:oauth:grant-type:token-exchange<br/>subject_token=<user_token><br/>requested_token_type=access_token<br/>audience=service-b
AS->>AS: Validate ServiceA's credentials<br/>Validate user token<br/>Issue down-scoped token for Service B
AS-->>ServiceA: New access_token<br/>(audience=service-b, narrower scope)
ServiceA->>ServiceB: API call with new token
Used in microservice chains where Service A calls Service B on behalf of the user — the token carries both Service A’s identity and the user’s delegated authority.
On-Behalf-Of (OBO) Flow
Microsoft Entra ID’s On-Behalf-Of flow is a specific implementation of Token Exchange: a middle-tier service presents the user’s access token and receives a new token scoped for a downstream API. The audit trail shows: user → Service A → Service B, with all three parties identified.
Macaroons and Biscuits — Offline Scope Attenuation
Macaroons and Biscuits are capability-based token formats that enable offline scope attenuation — restricting a token further without calling the AS.
A service receives a token with trades:read trades:write. It creates a restricted sub-token with only trades:read and passes it to a sub-agent. The sub-agent cannot expand the scope — Macaroon/Biscuit caveats only restrict, never expand. The original AS does not need to be contacted during attenuation.
This directly addresses the recursive delegation problem in agentic AI systems, will be discussed in the agentic identity posts — standard OAuth cannot enforce that permissions only narrow through a delegation chain without a round-trip to the AS.
DPoP — Proof-of-Possession Tokens
Bearer tokens have one weakness: anyone who steals the token can use it. DPoP (Demonstrating Proof-of-Possession) binds an access token to a key pair held by the client:
- Client generates an ephemeral key pair
- Client POSTs a signed DPoP proof (containing the public key and request details) alongside every API request
- The RS verifies that the token is bound to this public key — a stolen token is useless without the private key
DPoP is now widely adopted in financial-grade APIs (FAPI 2.0) and is the recommended approach for high-value OAuth deployments.
OAuth 2.1 — The Consolidated Standard
OAuth 2.1 consolidates the lessons of a decade of OAuth 2.0 deployment into a single clean document. Key changes from OAuth 2.0:
- PKCE is mandatory for all Authorization Code flows (not just public clients)
- Implicit grant is removed (tokens in URL fragments are insecure)
- ROPC grant is removed (clients should never see user passwords)
- Refresh token rotation is required for public clients
- Redirect URI exact match — no partial matching or wildcards
OAuth 2.1 is not a breaking change for most implementations — it is a tightening of the security baseline that reflects current best practice.
Why OAuth Is Critical for Modern Systems
mindmap
root((OAuth 2.0<br/>in Modern Systems))
API Economy
Third-party API access
Developer ecosystems
B2BC platforms
Mobile and SPA
PKCE for public clients
Refresh token rotation
Secure redirects
Microservices
Client credentials M2M
Token exchange between services
Service mesh token validation
Zero Trust
Token-based identity at every hop
Short-lived access
Scope-limited least privilege
Agentic AI
OAuth client credentials for agents
Token exchange for delegation chains
Scope attenuation via Macaroons/Biscuits
Key Takeaways
-
OAuth 2.0 is an authorization framework, not an authentication protocol. It delegates access — “this app may read my calendar” — but does not define who the user is. OIDC adds that identity layer.
-
There are four active grant types: Authorization Code (+ PKCE) for user-present flows; Client Credentials for M2M; Device Authorization for input-constrained devices; Refresh Token for silent renewal. Implicit and ROPC are deprecated in OAuth 2.1.
-
PKCE is non-negotiable for any public client (mobile, SPA). It prevents authorization code interception even if a redirect is hijacked.
-
JWTs are stateless and locally verifiable — the Resource Server validates the signature using the AS’s public key from the JWKS endpoint. No call to the AS per request.
-
Opaque tokens are better for immediate revocation. JWTs are valid until
exp— short TTLs (15–60 minutes) are the mitigation. -
The
.well-known/openid-configurationendpoint is the single source of truth for all AS endpoints and capabilities. Client libraries should always use discovery rather than hardcoded URLs. -
Custom claims let you embed authorization context (role, tier, risk limit) directly in the token — enabling stateless, database-free authorization at the Resource Server.
-
Token exchange (RFC 8693) enables controlled delegation across service chains. Macaroons/Biscuits enable offline scope attenuation for agentic and distributed systems.
-
DPoP binds tokens to a client’s key pair — stolen bearer tokens become useless without the corresponding private key. Required for financial-grade APIs.
-
OAuth 2.1 tightens the baseline: PKCE mandatory, Implicit removed, ROPC removed. Implementing against 2.1’s constraints is the correct default for any new system.
Discussion
Powered by Giscus via GitHub Discussions. A GitHub account is required to comment.