Obi Madu's Blog
Back to all articles
System DesignInfrastructureBackend

OAuth vs OIDC: Access Tokens vs ID Tokens

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

OAuth vs OIDC: Access Tokens vs ID Tokens

OAuth and OpenID Connect are two deeply foundational technologies that modern developers use constantly, yet they frequently describe them imprecisely. People casually say things like "OAuth login" or "sign in securely with OAuth." Sometimes that relaxed phrasing is just harmless industry shorthand, but other times it critically hides an incredibly important architectural distinction. OAuth 2.0 is fundamentally a framework built entirely around authorization. OpenID Connect, almost universally shortened to OIDC, is an identity layer strictly built for authentication.

That semantic difference sounds purely academic until you are actually building a real, production-ready application. If you operate using the wrong mental model, you can easily end up treating a standard access token as definitive proof of user identity, dangerously skipping critical token verification steps, storing highly sensitive tokens insecurely on mobile devices, or foolishly assuming that every single identity provider behaves exactly like Google.

The absolute simplest version of this distinction is this: OAuth firmly answers the crucial question, "What exactly is this specific application allowed to do?" Meanwhile, OIDC clearly answers the entirely different question, "Who precisely is this specific user?" OIDC is quite literally built directly on top of the established OAuth 2.0 framework, seamlessly adding a robust, highly standardized identity layer to OAuth's existing authorization flow.

The Wristband and ID Card Analogy

Imagine trying to enter an exclusive nightclub.

OAuth is essentially just like receiving a brightly colored wristband at the door. It clearly tells the bouncer and the venue staff exactly what areas you are formally permitted to access. Maybe your wristband means you can enter the roped-off VIP section, or perhaps it signifies you are allowed to order directly from the premium bar. In every sense, the wristband solely represents granted permission. But crucially, the wristband absolutely does not prove who you actually are. It does not display your legal name, it lacks your photo, it does not confirm your age, and it contains no official identity number.

OIDC is precisely like receiving that exact same wristband while simultaneously being forced to carry a highly trusted government ID card. You still retain all of your granted permissions via the wristband, but you now also possess a widely trusted, standardized identity document that definitively states who you actually are.

That scenario is exactly what happens technically in the software world:

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 the ID token are functional siblings. One token is absolutely not a substitute or a replacement for the other.

What OAuth 2.0 Actually Does

At its absolute core, OAuth 2.0 simply lets a user safely grant a third-party application explicit permission to access a heavily protected resource on their behalf. A very common real-world example is connecting a new app to your existing GitHub account. The application seamlessly redirects the user directly to GitHub and explicitly asks for targeted repository access.

https://github.com/login/oauth/authorize?
  client_id=abc123&
  scope=repo&
  redirect_uri=https://myapp.com/callback

GitHub subsequently presents the user with a clear consent screen. If the user affirmatively approves the request, GitHub securely redirects them back to the original app, carrying a temporary authorization code.

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

Behind the scenes, the application then securely exchanges that temporary code for a much more durable 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 provider's response importantly contains the newly minted access token.

{
  "access_token": "gho_16C7e42F292c6912E7710c838347Ae178B4a",
  "token_type": "bearer",
  "scope": "repo"
}

That specific token can now be used repeatedly to call highly protected GitHub APIs on the user's behalf.

GET https://api.github.com/user/repos
Authorization: Bearer gho_16C7e42F292c6912E7710c838347Ae178B4a

Notice carefully exactly what OAuth did fundamentally not give us throughout this entire process. It absolutely did not give us any kind of standard identity token. It strictly gave us highly scoped permission to call an API. With GitHub specifically, if you actually want to determine the user's identity, you are forced to use that access token to manually call an explicit identity API, such as GET /user. That extra step exists precisely because GitHub's most common login flow utilizes pure OAuth 2.0, rather than OIDC.

What OIDC Adds

OIDC intelligently starts with the exact same foundational OAuth flow but explicitly adds a highly required, standardized identity scope called openid. For example, a typical Google sign-in request almost always includes a cluster of scopes that looks exactly like this:

scope=openid profile email

That simple openid scope acts as the definitive signal to the provider that this is formally an OpenID Connect flow. The requesting app is explicitly stating that it is not merely asking for standard API access; it is actively asking the underlying provider to rigorously authenticate the user and then return their identity information in a highly standardized, globally recognized format.

After the standard code exchange finishes, an OIDC-compliant provider predictably returns both an access token and an ID token simultaneously.

{
  "access_token": "ya29.a0AfH6SMBxC...",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2...",
  "refresh_token": "1//04dGKC8...",
  "expires_in": 3600,
  "token_type": "Bearer"
}

The access token firmly remains designated exclusively for calling APIs. The newly introduced ID token functions as the highly trusted identity document. Crucially, the ID token is almost always formatted as a JWT (JSON Web Token), which strictly dictates that it possesses three distinct parts: a header, a payload, and a cryptographic signature.

header.payload.signature

The central payload heavily contains detailed claims regarding the specific user and the exact circumstances of the authentication event itself.

{
  "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 consistently serves as the highly stable, provider-side unique user ID. The iss claim reliably informs you exactly who securely issued the token. The aud claim definitively states which specific client application the token was intentionally issued for. The exp claim clearly defines exactly when the token mathematically expires. However, those embedded claims are completely useless and potentially dangerous unless you actively and correctly verify the token's cryptographic integrity first.

Never Just Decode an ID Token

An incredibly common, incredibly dangerous beginner mistake is to lazily split the JWT, decode the base64 payload, and blindly trust whatever text it happens to contain. That reckless process is absolutely not verification; it is merely text parsing.

A JWT is only genuinely useful specifically because it is cryptographically signed by the issuer. Your secure backend must rigorously verify that embedded signature against the identity provider's publicly hosted keys. Furthermore, it must heavily validate the issuer, strictly check the audience, enforce the expiration time, and aggressively validate any other specific claims your application critically depends on.

The standard backend verification checklist is remarkably simple in its core concept:

CheckWhy it matters
SignatureProves the token actually came from the trusted provider
IssuerPrevents accepting spoofed tokens from the wrong provider entirely
AudiencePrevents accidentally accepting valid tokens meant for another app
ExpirationStrictly prevents the malicious reuse of old, expired tokens
Email verificationEffectively prevents blindly trusting unverified, potentially fake email addresses

A highly simplified version of a secure backend verification function usually looks something 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 a true production environment, you invariably also need robust JWKS caching mechanisms, sophisticated key rotation handling logic, and deeply provider-specific validation rules. The most important overarching idea is that robust identity verification fundamentally belongs securely on the backend, absolutely not merely in frontend-decoded, easily manipulated token payloads.

Provider Differences Matter

It is vital to understand that not every single "Sign in with X" button actually utilizes OIDC underneath the hood. Massive providers like Google, Apple, Microsoft, Auth0, Keycloak, and Zitadel, along with numerous modern identity platforms, fully support the OIDC standard. In those highly modern flows, you can reliably expect to receive a standard, verifiable ID token.

However, GitHub's most commonly utilized OAuth application flow is distinctly different. It typically only provides an access token. To actually acquire the user's profile information, you are forced to use that token to call the GitHub API manually.

const userResponse = await fetch("https://api.github.com/user", {
  headers: {
    Authorization: `Bearer ${accessToken}`,
  },
});

const user = await userResponse.json();

That approach is not necessarily inherently worse, but it is undeniably different. Pure OAuth can absolutely still be successfully used in modern login flows, but the actual identity retrieval mechanism becomes entirely provider-specific unless the chosen provider explicitly supports the OIDC standard. This nuanced distinction heavily affects account linking strategies as well. With true OIDC, the sub claim consistently acts as the highly stable identity key for that specific provider. With OAuth-only providers, however, you must manually retrieve and carefully store the provider's unique user ID derived directly from its custom API response.

Managed Auth vs Self-Hosted OIDC

In the real world of application development, you almost always face a choice between utilizing a fully managed authentication provider and painstakingly implementing a robust OIDC integration entirely yourself.

Managed services like Clerk, Auth0, and Firebase Auth exist specifically to abstract away and hide the vast majority of the agonizing protocol details. They effortlessly handle dense provider configuration, complex token exchanges, aggressive token refreshing, secure session management, persistent user records, and deeply integrated SDKs. That "batteries-included" approach is incredibly attractive when sheer development speed genuinely matters.

Conversely, self-hosted identity providers like Zitadel or Keycloak offer significantly more granular control. They are frequently a vastly superior choice for strict data sovereignty requirements, intense regulatory compliance, deep workflow customization, or massive cost savings at enterprise scale. However, the trade-off is that you are heavily responsible for a much larger portion of the integration effort.

With a managed provider, your backend may simply rely on verifying a lightweight session token generated explicitly by that provider's custom 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 are typically fully responsible for manually configuring the authorization endpoint, the token endpoint, the client ID, the redirect URI, the requested scopes, and the intense backend JWT verification logic entirely from scratch. Neither specific approach is universally correct or incorrect. Managed authentication drastically reduces operational burden, while self-hosting dramatically increases long-term control.

Mobile Apps Need PKCE

It is absolutely crucial to realize that mobile applications are fundamentally not the exact same as traditional, server-rendered web applications. A traditional web application can safely and permanently store a highly sensitive client secret entirely on the backend server. A native mobile app absolutely cannot. Literally anything compiled and shipped inside a public APK or IPA file can be trivially extracted by a moderately determined user or automated script. That stark reality means native mobile apps must unequivocally be treated as highly vulnerable "public clients."

This glaring vulnerability is exactly why all mobile OAuth and OIDC flows should rigorously utilize PKCE, which stands for Proof Key for Code Exchange. PKCE ingeniously adds a temporary, highly dynamic, per-login secret to the flow. The mobile app locally generates a random code_verifier, securely hashes it into a code_challenge sent during the initial authorization request, and later conclusively proves it knows the original verifier when finally exchanging the code for actual 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 the popular Expo ecosystem, this complex dance is almost always neatly handled directly through the expo-auth-session library.

const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: CLIENT_ID,
    scopes: ["openid", "profile", "email"],
    redirectUri,
    usePKCE: true,
  },
  discovery
);

Furthermore, mobile apps desperately need incredibly secure local token storage. You must absolutely never dump highly sensitive tokens into unencrypted, easily readable async storage. In an Expo environment, you should always rigorously utilize expo-secure-store to lock down any 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 massive, persistent source of immense confusion is the simple fact that modern applications almost always juggle far more than just one kind of token simultaneously. The external identity provider may readily issue both an ID token and a separate access token. In response, your internal backend may then dynamically create its very own proprietary session token. To complicate matters, the provider might also simultaneously issue a long-lived refresh token.

Those heavily varied tokens serve completely different, highly specialized purposes within the architecture.

TokenIssuerPurpose
ID tokenIdentity providerDefinitively proves exactly who the user is
Access tokenIdentity or API providerStrictly authorizes downstream API calls
App session tokenYour app or auth platformAuthenticates ongoing requests directly to your backend
Refresh tokenProviderSecurely acquires new, short-lived tokens when old ones expire

A highly common, highly robust architectural flow usually looks somewhat like this:

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

Following that initial exchange, your client app may simply utilize your own heavily customized session token to efficiently call your internal API. It absolutely does not need to continuously transmit the massive Google ID token on every single network request, unless that is specifically how your peculiar architecture was intentionally designed.

Common Misunderstandings

The absolute most common, pervasive mistake in the industry is casually declaring that OAuth literally means "login." OAuth can certainly be heavily utilized to securely participate in a broader login flow, but OAuth itself is strictly and entirely about delegated authorization. OIDC is the actual, heavily standardized identity layer built specifically for logging people in.

Another massive, frequently encountered mistake is attempting to use the ID token to directly authorize API calls. The ID token exists exclusively for the frontend client or the backend server to definitively understand the user's identity. The access token exists exclusively for robust API authorization. Blurring those two lines constantly leads to severe architectural flaws.

Logout behavior represents another incredibly subtle area of confusion. Logging a user out of your specific application usually just clears your local app session and furiously deletes local tokens. It absolutely does not necessarily force the user to log out of Google, Apple, or every single other connected app sitting on their physical device. That firm separation of session state is entirely normal and expected.

Finally, requested permission scopes should always be kept as minimal as humanly possible. If your app only genuinely needs to know the user's identity, you should only ever request the openid profile email scopes. You should absolutely never casually ask for sweeping access to their calendar, private drive, personal photos, or other broad permissions unless your application actually fundamentally requires them to function.

The Practical Summary

OAuth 2.0 is fundamentally the permission framework. It definitively lets a given application do something incredibly specific on behalf of a user. OIDC is the standardized identity layer sitting directly on top. It securely lets an application reliably know exactly who the user actually is in a universally standard way.

Use the access token exclusively for calling APIs. Use the ID token exclusively for verifying identity. Always rigorously verify tokens squarely on the backend. Always aggressively enforce PKCE for native mobile applications. Always store sensitive tokens in highly secure, encrypted storage. Always treat identity provider differences as highly real, architectural constraints, not merely cosmetic variations.

If you remember absolutely nothing else from this article, force yourself to remember this one ironclad rule: OAuth merely gives your app permission, but OIDC definitively gives your app identity.

References