Obi Madu's Blog
Back to all articles
System DesignInfrastructureBackend

OAuth vs OIDC: The Difference Finally Explained

A practical explanation of OAuth, OIDC, access tokens, and ID tokens without the usual authentication confusion.

OAuth vs OIDC: The Difference Finally Explained

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 user

The 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/callback

GitHub shows a consent screen. If the user approves, GitHub redirects back with an authorization code.

https://myapp.com/callback?code=xyz789

The 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/callback

The 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_16C7e42F292c6912E7710c838347Ae178B4a

Notice 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 email

That 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.signature

The 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:

CheckWhy it matters
SignatureProves the token came from the provider
IssuerPrevents accepting tokens from the wrong provider
AudiencePrevents accepting tokens meant for another app
ExpirationPrevents reuse of old tokens
Email verificationPrevents 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 payload

In 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.payload

With 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 challenge

In 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.

TokenIssuerPurpose
ID tokenIdentity providerProves who the user is
Access tokenIdentity or API providerAuthorizes API calls
App session tokenYour app or auth platformAuthenticates requests to your backend
Refresh tokenProviderGets new short-lived tokens

A common flow looks like this:

Google ID token -> Your backend verifies it -> Your backend creates an app session

After 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