How do OAuth 2.0 and OpenID Connect differ?
OAuth 2.0 gets an app an access token to call an API; OpenID Connect adds an ID token that says who signed in. The code flow with PKCE, request by request.
Intermediate7 min read
app auth server API │ │ │ │ 1 /authorize + challenge │ │ ●──────────────────────────▶│ │ │ │──┐ 2 user signs in │ │ │◀─┘ │ │ 3 302 ?code=...&state= │ │ │◀──────────────────────────│ │ │ 4 POST /token + verifier │ │ │──────────────────────────▶│ │ │ 5 access_token, id_token │ │ │◀──────────────────────────│ │ │ 6 GET /api Authorization: Bearer │ │────────────────────────────────────────────────────▶│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ id_token who signed in: iss sub aud exp nonce access_token what the app may call at the API OpenID Connect adds the id_token to OAuth 2.0
On this page
OAuth 2.0 is about authorization: it lets an app obtain an access token and call an API on a user's behalf without ever seeing the user's password. OpenID Connect (OIDC) is about authentication: a layer on top of OAuth 2.0 that tells the app who signed in, through a second token called the ID token.
Both use the same flow. With OIDC, the request includes openid in its scope, and the token response carries an extra id_token. Below I walk through the authorization code flow with PKCE one request at a time, then look at which token is meant for whom.
app auth server API │ │ │ │ 1 /authorize + challenge │ │ ●──────────────────────────▶│ │ │ │──┐ 2 user signs in │ │ │◀─┘ │ │ 3 302 ?code=...&state= │ │ │◀──────────────────────────│ │ │ 4 POST /token + verifier │ │ │──────────────────────────▶│ │ │ 5 access_token, id_token │ │ │◀──────────────────────────│ │ │ 6 GET /api Authorization: Bearer │ │────────────────────────────────────────────────────▶│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ id_token who signed in: iss sub aud exp nonce access_token what the app may call at the API OpenID Connect adds the id_token to OAuth 2.0
What is OAuth 2.0 for?
It lets a third-party application obtain limited access to an HTTP service, with the owner of the data approving. RFC 6749 defines four roles:
| Role | In this example |
|---|---|
| Resource owner | The user who owns the data |
| Client | The app that wants to call the API |
| Authorization server | Authenticates the user and issues tokens |
| Resource server | The API that accepts access tokens |
What OAuth produces is an access token, which RFC 6749 describes as a string representing an authorization issued to the client. The string is usually opaque to the client (section 1.4). It stands for a scope and a lifetime, and the resource server enforces it. That is exactly why plain OAuth is not enough for sign-in: the token is built for the API to read, not for the app to learn who the user is.
What does OpenID Connect add?
The ID token. OpenID Connect Core 1.0 describes itself as a simple identity layer on top of the OAuth 2.0 protocol that enables Clients to verify the identity of the End-User based on the authentication performed by an Authorization Server. Its main addition is the ID token, which is represented as a JSON Web Token (JWT) and which MUST be signed using JWS.
Every ID token carries these claims (OIDC Core, section 2):
iss: the issuer's URL.sub: a locally unique and never reassigned identifier within the Issuer for the End-User.aud: the audience, which MUST contain the OAuth 2.0 client_id of the Relying Party.expandiat: expiry and issue time.
nonce is used to associate a Client session with an ID Token, and to mitigate replay attacks: if the app sends one in the request, the same value comes back in the token.
OIDC also defines the UserInfo endpoint, an OAuth 2.0 Protected Resource that returns Claims about the authenticated End-User, called with the access token from the same flow. And every OIDC request MUST contain the openid scope value.
How does the authorization code flow with PKCE work?
Six steps. The app sends the browser to the authorization server, the user signs in, the app receives a code, and then trades the code for tokens over a direct back-channel request. PKCE (RFC 7636) adds a secret that ties the first request to the token request. The values below come from the examples in RFC 6749, RFC 7636 and OIDC Core.
Step 1: the app creates a code_verifier and code_challenge
The app generates a random code_verifier with a minimum length of 43 characters and a maximum length of 128 characters and derives the code_challenge:
code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
code_verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
This pair is from Appendix B of RFC 7636. The method is called S256, and a client capable of using "S256" MUST use "S256". The verifier stays inside the app for now.
Step 2: the browser goes to /authorize
GET /authorize?response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
&scope=openid%20profile
&state=af0ifjsldkj
&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: server.example.com
response_type=code is REQUIRED and asks for an authorization code. state is a value the app picks; the authorization server includes this value when redirecting the user-agent back to the client, and it should be used against cross-site request forgery. scope=openid makes this an OIDC request; without it, this is plain OAuth.
Step 3: the user signs in and the browser comes back with a code
The authorization server authenticates the user and asks for consent, then redirects to redirect_uri:
HTTP/1.1 302 Found
Location: https://client.example.org/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj
The app checks that state matches what it sent. The code is short-lived: RFC 6749 recommends a maximum authorization code lifetime of 10 minutes, and the client MUST NOT use the authorization code more than once.
Because the code travels through the browser and a URL, it can leak. PKCE was designed for the authorization code interception attack, for example a malicious app on the same device that registers the same custom URI scheme as the real one.
Step 4: the app exchanges the code for tokens
This request goes straight to the token endpoint, not through the browser:
POST /token HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
&client_id=s6BhdRkqt3
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
The server verifies it by calculating the code challenge from the received "code_verifier" and comparing it with the previously associated "code_challenge". No match, no tokens. Someone who intercepted only the code cannot redeem it, because the verifier never passed through the browser.
Step 5: the app receives an access token and an ID token
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "SlAV32hkKG",
"token_type": "Bearer",
"refresh_token": "8xLOxBtZp8",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkazcifQ...."
}
This is the example token response in OIDC Core. Decoding the payload of that example id_token gives:
{
"iss": "http://server.example.com",
"sub": "248289761001",
"aud": "s6BhdRkqt3",
"nonce": "n-0S6_WzA2Mj",
"exp": 1311281970,
"iat": 1311280970
}
Before trusting it, the app validates it as OIDC Core requires: the issuer identifier MUST exactly match the value of the iss (issuer) Claim, aud must contain the app's client_id, and the token must not be expired. (The spec's example iss uses http, but real values are a case-sensitive URL using the https scheme.) When the ID token arrives directly from the token endpoint, as here, the TLS server validation MAY be used to validate the issuer in place of checking the token signature; an ID token received any other way must have its signature checked with the issuer's keys.
Step 6: the app calls the API
GET /api/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer SlAV32hkKG
The Authorization: Bearer form comes from RFC 6750, which defines a bearer token as a security token with the property that any party in possession of the token (a "bearer") can use the token in any way that any other party in possession of it can. No proof of a key is needed, so an access token deserves the same care as a password.
How is an ID token different from an access token?
The ID token is for the app, to learn who signed in. The access token is for the API, to decide whether to allow a request. They are not interchangeable.
| ID token | Access token | |
|---|---|---|
| Defined by | OpenID Connect Core | OAuth 2.0 (RFC 6749) |
| Audience | The client (aud = client_id) |
The resource server |
| Format | Always a signed JWT | Not specified: random string or JWT |
| Used for | Who signed in, and when | Calling the API within its scope |
| Sent to the API | No | Yes, as Authorization: Bearer |
If the access token is a random string, the API can ask the authorization server about it with token introspection (RFC 7662), using an HTTP POST and getting back whether the token is active. If it is a JWT, RFC 9068 defines a profile where the typ header is at+jwt; one reason it gives is preventing OpenID Connect ID Tokens from being accepted as access tokens by resource servers, since both can be JWTs from the same issuer.
Mixing them up goes wrong in two ways. An API that accepts an ID token as permission to call it is trusting a token whose aud is the app, not the API. An app that treats an access token as proof the user signed in is relying on a token that says nothing about who the user is, and that the app should not need to read at all.
What did RFC 9700 change?
RFC 9700 (January 2025) is the OAuth 2.0 Security Best Current Practice. It updates and extends the threat model and security advice given in RFCs 6749, 6750, and 6819 and deprecates some modes it considers insecure. The points that touch the flow above:
- PKCE for every client. Public clients MUST use PKCE, it is RECOMMENDED for confidential clients, and authorization servers MUST support PKCE. Although PKCE was designed for native apps, this advice applies to all kinds of OAuth clients, including web applications.
S256only. Of the methods that keep the verifier out of the authorization request, S256 is the only such method.- No implicit grant. Clients SHOULD NOT use the implicit grant (response type token) or other response types that return access tokens in the authorization response.
- No password grant. The resource owner password credentials grant MUST NOT be used, because it exposes the user's credentials to the client.
- Exact redirect URI matching. Authorization servers MUST utilize exact string matching, except for port numbers in localhost redirect URIs of native apps.
- Narrow tokens. Access tokens SHOULD be audience-restricted to one resource server or a small set, and refresh tokens for public clients MUST be sender-constrained or use refresh token rotation.
For OIDC, RFC 9700 allows one exception: with extra precautions (section 4.5.3.2), confidential OpenID Connect clients MAY use the nonce parameter instead of PKCE. On a new system I would simply use both PKCE and nonce. It is a few extra lines, and it saves checking whether the exception's conditions hold.
Sources
- RFC 6749: The OAuth 2.0 Authorization Framework
- RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage
- RFC 7636: Proof Key for Code Exchange by OAuth Public Clients
- RFC 7662: OAuth 2.0 Token Introspection
- RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens
- RFC 9700: Best Current Practice for OAuth 2.0 Security
- OpenID Connect Core 1.0 incorporating errata set 2
Keep reading
Related posts
Building software
RPC vs REST: what's the difference, and which should you use?
RPC calls a named procedure on a server; REST sends requests to resources through HTTP's uniform interface. A side-by-side comparison with gRPC and JSON-RPC examples, from Fielding, RFC 9110 and the specs.
Beginner10 min read
- API
- RPC
- REST
- gRPC
- JSON-RPC
- HTTP
Building software
WebSocket vs SSE vs polling: how each works and when to use it
Polling sends a new request each round, SSE holds one response open for server events, WebSocket upgrades to two-way frames. Reconnects, proxies and firewalls.
Intermediate9 min read
- WebSocket
- Server-Sent Events
- SSE
- Long Polling
- HTTP
- Reverse Proxy
- nginx
Building software
How do Docker and Kubernetes differ, and when do you need Kubernetes?
Docker builds images and runs containers on one host. Kubernetes runs them across many nodes, schedules Pods, keeps replica counts and exposes Services.
Beginner5 min read
- Docker
- Kubernetes
- Containers
- Docker Compose
- OCI
- DevOps