OAuth and OpenID Connect are two of those technologies that developers use all the time but often describe imprecisely.
People say things like "OAuth login" or "sign in with OAuth." Sometimes that is harmless shorthand. Other times it hides an important distinction. OAuth 2.0 is about authorization. OpenID Connect, usually shortened to OIDC, is about authentication.
That difference sounds academic until you are building a real app. If you use the wrong mental model, you can end up treating an access token as proof of identity, skipping token verification, storing tokens insecurely on mobile, or assuming every provider behaves like Google.
The simplest version is this:
OAuth answers, "What is this app allowed to do?"
OIDC answers, "Who is this user?"
OIDC is built on top of OAuth 2.0. It adds a standard identity layer to OAuth's authorization flow.
The Wristband and ID Card Analogy
Imagine a nightclub.
OAuth is like a wristband. It tells the staff what areas you can access. Maybe you can enter the VIP section. Maybe you can order from a premium bar. The wristband represents permission.
But the wristband does not prove who you are. It does not show your name, photo, age, or identity number.
OIDC is like getting the wristband plus an ID card. You still have permissions, but you also have a trusted identity document that says who you are.
That is what happens technically.
OAuth 2.0 gives you:
Access token
Used to call APIs
OIDC gives you:
Access token
Used to call APIs
ID token
Used to identify the userThe access token and ID token are siblings. One is not a replacement for the other.
What OAuth 2.0 Actually Does
OAuth 2.0 lets a user grant an application permission to access a resource.
A common example is connecting an app to GitHub. The app redirects the user to GitHub and asks for repository access.
https://github.com/login/oauth/authorize?
client_id=abc123&
scope=repo&
redirect_uri=https://myapp.com/callbackGitHub shows a consent screen. If the user approves, GitHub redirects back with an authorization code.
https://myapp.com/callback?code=xyz789The app exchanges that code for an access token.
POST https://github.com/login/oauth/access_token
Content-Type: application/x-www-form-urlencoded
client_id=abc123&
client_secret=secret456&
code=xyz789&
redirect_uri=https://myapp.com/callbackThe response contains an access token.
{
"access_token": "gho_16C7e42F292c6912E7710c838347Ae178B4a",
"token_type": "bearer",
"scope": "repo"
}That token can call GitHub APIs.
GET https://api.github.com/user/repos
Authorization: Bearer gho_16C7e42F292c6912E7710c838347Ae178B4aNotice what OAuth did not give us. It did not give us a standard identity token. It gave us permission to call an API.
With GitHub specifically, if you want user identity, you must call an API such as GET /user. That is because GitHub's common login flow is OAuth 2.0, not OIDC.
What OIDC Adds
OIDC starts with the OAuth flow and adds a required identity scope: openid.
For example, a Google sign-in request usually includes scopes like this:
scope=openid profile emailThat openid scope is the signal that this is an OpenID Connect flow. The app is not only asking for API access. It is asking the provider to authenticate the user and return identity information in a standard format.
After the code exchange, an OIDC provider returns an access token and an ID token.
{
"access_token": "ya29.a0AfH6SMBxC...",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2...",
"refresh_token": "1//04dGKC8...",
"expires_in": 3600,
"token_type": "Bearer"
}The access token is still for APIs. The ID token is the identity document.
The ID token is a JWT, which means it has three parts: header, payload, and signature.
header.payload.signatureThe payload contains claims about the user and the authentication event.
{
"sub": "109651783456723954281",
"name": "John Doe",
"email": "john.doe@gmail.com",
"email_verified": true,
"picture": "https://lh3.googleusercontent.com/a-/abc123",
"iss": "https://accounts.google.com",
"aud": "1234567890-abcd1234.apps.googleusercontent.com",
"iat": 1712345678,
"exp": 1712349278
}The sub claim is the stable provider-side user ID. The iss claim tells you who issued the token. The aud claim tells you which client the token was issued for. The exp claim tells you when it expires.
Those claims are useful only if you verify the token correctly.
Never Just Decode an ID Token
A common beginner mistake is to split the JWT, decode the payload, and trust what it says.
That is not verification. That is just parsing.
A JWT is useful because it is signed. Your backend must verify that signature against the provider's public keys. It must also check the issuer, audience, expiration, and any claims your app depends on.
The backend verification checklist is simple in concept:
| Check | Why it matters |
|---|---|
| Signature | Proves the token came from the provider |
| Issuer | Prevents accepting tokens from the wrong provider |
| Audience | Prevents accepting tokens meant for another app |
| Expiration | Prevents reuse of old tokens |
| Email verification | Prevents trusting unverified email addresses |
A simplified backend verification function looks like this:
def verify_token(token):
public_key = fetch_provider_public_key(token)
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience=YOUR_CLIENT_ID,
issuer="https://accounts.google.com",
)
if not payload.get("email_verified"):
raise ValueError("Email is not verified")
return payloadIn production, you also need proper JWKS caching, key rotation handling, and provider-specific validation rules. The important idea is that identity belongs on the backend, not only in frontend-decoded token payloads.
Provider Differences Matter
Not every "Sign in with X" button means OIDC.
Google, Apple, Microsoft, Auth0, Keycloak, Zitadel, and many modern identity providers support OIDC. In those flows, you can receive a standard ID token.
GitHub's common OAuth app flow is different. It gives you an access token. To get the user profile, you call the GitHub API.
const userResponse = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const user = await userResponse.json();That is not worse. It is just different. OAuth can still be used in login flows, but the identity retrieval is provider-specific unless the provider supports OIDC.
This distinction also affects account linking. With OIDC, the sub claim is the stable identity key for that provider. With OAuth-only providers, you need to retrieve and store the provider's user ID from its API.
Managed Auth vs Self-Hosted OIDC
In real apps, you usually choose between using a managed auth provider and implementing an OIDC integration yourself.
Managed services like Clerk, Auth0, and Firebase Auth hide most of the protocol details. They handle provider configuration, token exchange, refresh, sessions, user records, and SDK integrations. That is attractive when speed matters.
Self-hosted providers like Zitadel or Keycloak give more control. They can be better for data sovereignty, compliance, customization, or cost at scale. But you are responsible for more of the integration.
With a managed provider, your backend may verify a session token issued by that provider's SDK.
async def require_auth(request):
request_state = clerk.authenticate_request(
request={"headers": dict(request.headers), "url": str(request.url)}
)
if not request_state.is_signed_in:
raise HTTPException(status_code=401)
return request_state.payloadWith a self-hosted OIDC provider, you typically configure the authorization endpoint, token endpoint, client ID, redirect URI, scopes, and backend JWT verification yourself.
Neither approach is universally correct. Managed auth reduces operational burden. Self-hosting increases control.
Mobile Apps Need PKCE
Mobile apps are not the same as traditional server-rendered web apps.
A web app can keep a client secret on the server. A native app cannot. Anything shipped inside an APK or IPA can be extracted by a determined user. That means native apps must be treated as public clients.
This is why mobile OAuth and OIDC flows should use PKCE, which stands for Proof Key for Code Exchange.
PKCE adds a temporary per-login secret. The app generates a code_verifier, sends a hashed code_challenge during authorization, and later proves it knows the original verifier when exchanging the code for tokens.
1. App creates a random code_verifier
2. App hashes it into a code_challenge
3. Authorization request includes the code_challenge
4. Provider returns an authorization code
5. Token request includes the original code_verifier
6. Provider checks that it matches the challengeIn Expo, this is usually handled through expo-auth-session.
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{
clientId: CLIENT_ID,
scopes: ["openid", "profile", "email"],
redirectUri,
usePKCE: true,
},
discovery
);Mobile apps also need secure token storage. Do not put tokens in unencrypted async storage. In Expo, use expo-secure-store for sensitive token material.
import * as SecureStore from "expo-secure-store";
await SecureStore.setItemAsync("id_token", idToken);
const savedToken = await SecureStore.getItemAsync("id_token");The Three Token Layers
One source of confusion is that apps often have more than one kind of token.
The provider may issue an ID token and access token. Your backend may then create its own session token. The provider may also issue a refresh token.
Those tokens serve different purposes.
| Token | Issuer | Purpose |
|---|---|---|
| ID token | Identity provider | Proves who the user is |
| Access token | Identity or API provider | Authorizes API calls |
| App session token | Your app or auth platform | Authenticates requests to your backend |
| Refresh token | Provider | Gets new short-lived tokens |
A common flow looks like this:
Google ID token -> Your backend verifies it -> Your backend creates an app sessionAfter that, your app may use your own session token to call your API. It does not need to send the Google ID token on every request unless that is how your architecture is designed.
Common Misunderstandings
The most common mistake is saying OAuth means login. OAuth can participate in a login flow, but OAuth itself is about delegated authorization. OIDC is the standard identity layer.
Another mistake is using the ID token to call APIs. The ID token is for the client or backend to understand identity. The access token is for API authorization.
Logout is another subtle one. Logging out of your app usually clears your app session and local tokens. It does not necessarily log the user out of Google, Apple, or every other app on the device. That separation is normal.
Finally, scopes should be minimal. If your app only needs identity, request openid profile email. Do not ask for calendar, drive, photos, or other broad permissions unless you actually need them.
The Practical Summary
OAuth 2.0 is the permission framework. It lets an app do something on behalf of a user.
OIDC is the identity layer on top. It lets an app know who the user is in a standard way.
Use the access token for APIs. Use the ID token for identity. Verify tokens on the backend. Use PKCE for native apps. Store tokens securely. Treat provider differences as real, not cosmetic.
If you remember only one thing, remember this: OAuth gives your app permission, OIDC gives your app identity.
References
Read more
Architecting Reliable Mobile Billing: What I Learned the Hard Way
A real-world look at fixing mobile subscription billing when webhooks, sandbox purchases, and user identity break down.
React Native and Expo: The Mental Model That Makes It Click
Understand how React Native and Expo really work, from native builds to OTA updates and development workflows.
Coder Tasks, Workspaces, and OpenCode: A Practical Mental Model
Build a clearer mental model for Coder workspaces, templates, provisioners, and AI coding tasks.
