NashTech Blog

Table of Contents

If you’ve built modern web or mobile applications, you’ve almost certainly come across OAuth 2.0 and OpenID Connect (OIDC). They’re often mentioned in the same breath, sometimes confused with one another, and occasionally treated as if they’re the same thing.

But they’re not the same thing.

In this post, I’ll explain:

  • What OAuth 2.0 actually does
  • What OpenID Connect adds on top of it
  • How PKCE and state parameters protect your OAuth flows
  • When and why you should use each one

The Problem They Solve

Modern applications rarely work in isolation:

  • Your frontend (web or mobile) talks to APIs
  • APIs call other APIs
  • Users sign in with Google, Apple, Microsoft, or enterprise identity providers
  • You don’t want to handle passwords yourself (and you shouldn’t)

You need a secure, standardized way to:

  1. Authenticate users
  2. Let applications access APIs on behalf of users
  3. Avoid sharing credentials
  4. Support multiple clients (web, mobile, backend services)

This is where OAuth 2.0 and OIDC come in.


OAuth 2.0: Authorization, Not Authentication

What OAuth 2.0 Is

OAuth 2.0 is an authorization framework.

Its core purpose is straightforward:

Allow an application to access a resource on behalf of a user—without ever knowing the user’s credentials.

OAuth answers the question:

“Is this client allowed to do X on resource Y?”

But it does not answer:

“Who is the user?”

This distinction is critical.


Real-World Explain

Think of OAuth like a hotel key card:

  • The card grants you access to certain rooms
  • It doesn’t prove who you are
  • It only proves what you’re allowed to access

Core OAuth Roles

OAuth defines four main roles:

  1. Resource Owner The user who owns the data.
  2. Client The application requesting access (web app, mobile app, or backend service).
  3. Authorization Server Issues tokens after the user grants consent (e.g., Auth0, Azure AD, Google).
  4. Resource Server The API that holds the protected data.

OAuth Tokens

OAuth introduces Access Tokens:

  • Short-lived
  • Sent with API requests (Authorization: Bearer <token>)
  • Represent permissions (scopes), not identity

Example scopes:

read:photos
upload:media
manage:billing

Limitation of OAuth

One thing to note that the access token from OAuth doesn’t necessarily have to be JWT, it means that it can be a string, a text such as ‘A21AAJxz_Pvi7ZIUPzFf9H3cUp’ or ‘$E@c&et’. So that it cannot reliably authenticate users.

  • Access tokens are designed for APIs, not identity verification
  • They can be opaque (unreadable)
  • They may not contain user identity information
  • OAuth doesn’t standardize how to identify a user

This is where OIDC comes in.


OpenID Connect (OIDC): Authentication Built on OAuth

What OIDC Is

OpenID Connect is an identity layer built on top of OAuth 2.0.

OIDC answers the question:

“Who is the user?”

What make the OIDC and OAuth different is the value ‘openid’ in the scope attribute, consider the below authorization_url of PayPal. When requesting an openid in the scope, the authorization server automatically know that you want to not only authorize user but also authenticate user, it will then response the idToken back along with accessToken

{
"access_token": "A21AAJxz_Pvi7ZIUPzFf9HebQ9ZSZsVM8hf0z9Y0",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2In0.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiI4MzkyMDA5OCIsImF1ZCI6Im15LWNsaWVudC1pZCIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsIm5hbWUiOiJKYW5lIERvZSIsImlhdCI6MTcxMjM0MDAwMCwiZXhwIjoxNzEyMzQzNjAwfQ.XYZ…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email"
}

The ID Token

The key innovation in OIDC is the ID Token.

An ID Token is:

  • A JWT (JSON Web Token)
  • Issued by the authorization server
  • Cryptographically signed
  • Intended for the client, not APIs

Example claims inside an ID Token:

{
"iss": "<https://auth.example.com>",
"sub": "123456789",
"email": "user@example.com",
"name": "Jane Doe",
"exp": 1712345678
}

Common OAuth / OIDC Flows

Authorization Code Flow

  1. The user is redirected to the Authorization Server
  2. The user authenticates (via password, MFA, biometrics, or SSO)
  3. The Authorization Server issues:
    • ID Token → for the client (to verify identity)
    • Access Token → for APIs (to grant authorization)
  4. The client:
    • Reads user information from the ID Token
    • Stores the Access Token
  5. The client calls the API with the Access Token
  6. The API validates the token and checks its scopes

Client Credentials Flow

The Client Credentials flow is used in server-to-server authentication. Since this flow does not include authorization, only endpoints that do not access user information can be accessed.

The following diagram shows how the Client Credentials Flow taken from Spotify works:

Reference: https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow

I prepare an example request which is from PayPal like this, it return the responses which contain the accessToken, and use Bearer token which combines ClientId, and Client Secret

PKCE (Proof Key for Code Exchange)

The Problem: Authorization Code Interception

In the traditional Authorization Code flow, there’s a vulnerability for public clients (apps that can’t securely store secrets):

  1. Attacker intercepts the authorization code (from the redirect URL)
  2. Attacker exchanges the code for tokens at the token endpoint
  3. Attacker gains access to the user’s account

This is especially risky for:

  • Mobile apps (custom URL schemes can be hijacked)
  • Single-page applications (no backend to hide secrets)
  • Any scenario where the client secret can’t be kept confidential

The core issue: Without a client secret, anyone who intercepts the authorization code can use it.


How PKCE Solves This

PKCE adds a dynamic, one-time secret to the authorization flow.

Here’s how it works:

Step 1: Client creates a secret

// Generate a random code verifier
const codeVerifier = generateRandomString(43);
// Create a code challenge from the verifier
const codeChallenge = base64UrlEncode(sha256(codeVerifier));

Step 2: Authorization request

GET /authorize?
response_type=code
&client_id=myapp
&redirect_uri=https://myapp.com/callback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256

The client sends the code challenge (hashed version) but keeps the code verifier secret.

Step 3: Token exchange

POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE_HERE
&redirect_uri=https://myapp.com/callback
&client_id=myapp
&code_verifier=ORIGINAL_CODE_VERIFIER

The authorization server:

  1. Hashes the code_verifier
  2. Compares it to the code_challenge from step 2
  3. Only issues tokens if they match

Why this works:

  • The attacker can intercept the authorization code
  • But they don’t have the code verifier (it never left the client)
  • Without the correct verifier, they can’t exchange the code for tokens

The State Parameter

The Problem: CSRF Attacks

Without proper protection, your OAuth flow is vulnerable to Cross-Site Request Forgery (CSRF) attacks:

Attack scenario:

  1. Attacker initiates an OAuth flow and captures the authorization response
  2. Attacker tricks the victim into visiting a malicious page
  3. The malicious page triggers the OAuth callback with the attacker’s authorization code
  4. The victim’s session is now linked to the attacker’s account
  5. The victim unknowingly uploads data to the attacker’s account

How State Solves This

The state parameter acts as a CSRF token for OAuth flows.

How it works:

Step 1: Generate a unique state value

// Before redirecting to authorization server
const state = generateRandomString(32);
// Store it in session or local storage
sessionStorage.setItem('oauth_state', state);

Step 2: Include state in authorization request

GET /authorize?
response_type=code
&client_id=myapp
&redirect_uri=https://myapp.com/callback
&scope=openid profile email
&state=xyzABC123RandomString

Step 3: Validate state in callback

// In your callback handler
const receivedState = urlParams.get('state');
const storedState = sessionStorage.getItem('oauth_state');
if (receivedState !== storedState) {
throw new Error('Invalid state - possible CSRF attack');
}

Why this works:

  • Each OAuth flow has a unique, unpredictable state value
  • The attacker can’t guess or obtain the victim’s state value
  • If the state doesn’t match, the flow is rejected
  • Ties the authorization response to the specific user’s session

Picture of Khai Nguyen Duc

Khai Nguyen Duc

Leave a Comment

Suggested Article

Discover more from NashTech Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading