OAuth 2.0 vs OIDC: A Complete Guide for Developers

Comparing OAuth 2.0 and OpenID Connect in a Technical Deep Dive

Systems

white and black typewriter with white printer paper

The Core Distinction

OAuth 2.0 = Authorization (Permission)

  • Grants access to resources/APIs

  • Answers: "Can this app do X on my behalf?"

  • Example: "Allow this app to post to my Twitter"

OIDC (OpenID Connect) = Authentication (Identity)

  • Proves who the user is

  • Answers: "Who is this person?"

  • Example: "This is John Doe, email: john@example.com"

Key Relationship: OIDC is built ON TOP of OAuth 2.0. It adds an identity layer to the authorization framework.


Real-World Analogy

The Nightclub Scenario

Imagine going to an exclusive nightclub with two different entry methods:

OAuth 2.0 = VIP Wristband ๐ŸŽŸ๏ธ

  • The bouncer gives you a wristband

  • Wristband says: "Can enter VIP section" and "Can order from premium bar"

  • What it doesn't say: Your name, age, or who you are

  • It's just permission to access areas

OIDC = VIP Wristband + Driver's License ๐ŸŽŸ๏ธ + ๐Ÿชช

  • Same wristband for permissions

  • PLUS a window showing your driver's license with:

    • Your photo

    • Your name: "John Doe"

    • Your ID number: 123456

  • This proves WHO you are, not just what you can do


The Technical Translation

When you authenticate with a provider:




Critical Point: You typically get BOTH tokens in an OIDC flow. They're siblings, not replacements for each other.

How OAuth 2.0 Works

The Protocol Flow

Scenario: A third-party app wants to access your GitHub repositories.

Step 1: User clicks "Connect to GitHub"




Step 2: GitHub asks permission

  • Shows: "MyApp wants to access your repositories"

  • User clicks "Authorize"

Step 3: GitHub redirects back

Step 4: App exchanges code for 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
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
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

Response:

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

Step 5: App uses token to call APIs

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

Notice: OAuth 2.0 doesn't tell you who the user is. You only get an access token for API calls. To get user identity, you need an additional API call to GET /user.


How OIDC Works

The Protocol Flow

Scenario: User signs in to your app with Google.

Step 1: User clicks "Sign in with Google"




Key Differences from OAuth:

  • scope includes openid (required for OIDC)

  • scope includes profile and email (identity scopes)

Step 2: Google asks permission

  • Shows: "MyApp wants to: See your profile info, View your email address"

  • User clicks "Continue"

Step 3: Google redirects back

Step 4: App exchanges code for tokens

POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=auth_code_123&
redirect_uri=https://myapp.com/callback&
client_id=123.apps.googleusercontent.com&
client_secret=secret456
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=auth_code_123&
redirect_uri=https://myapp.com/callback&
client_id=123.apps.googleusercontent.com&
client_secret=secret456
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=auth_code_123&
redirect_uri=https://myapp.com/callback&
client_id=123.apps.googleusercontent.com&
client_secret=secret456

Response:

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

Key Difference: You get TWO tokens:

  • access_token - For calling Google APIs (OAuth 2.0 part)

  • id_token - JWT containing user identity (OIDC part)

Step 5: Decode the ID Token

// JWT: header.payload.signature
const idTokenParts = id_token.split('.');
const payload = JSON.parse(atob(idTokenParts[1]));

console.log(payload);
// {
//   "sub": "1234567890",
//   "name": "John Doe",
//   "email": "john@gmail.com",
//   "picture": "https://...",
//   "iss": "https://accounts.google.com",
//   "aud": "123.apps.googleusercontent.com",
//   "iat": 1712345678,
//   "exp": 1712349278
// }
// JWT: header.payload.signature
const idTokenParts = id_token.split('.');
const payload = JSON.parse(atob(idTokenParts[1]));

console.log(payload);
// {
//   "sub": "1234567890",
//   "name": "John Doe",
//   "email": "john@gmail.com",
//   "picture": "https://...",
//   "iss": "https://accounts.google.com",
//   "aud": "123.apps.googleusercontent.com",
//   "iat": 1712345678,
//   "exp": 1712349278
// }
// JWT: header.payload.signature
const idTokenParts = id_token.split('.');
const payload = JSON.parse(atob(idTokenParts[1]));

console.log(payload);
// {
//   "sub": "1234567890",
//   "name": "John Doe",
//   "email": "john@gmail.com",
//   "picture": "https://...",
//   "iss": "https://accounts.google.com",
//   "aud": "123.apps.googleusercontent.com",
//   "iat": 1712345678,
//   "exp": 1712349278
// }

No extra API call needed - the user identity is right there in the ID Token!


The ID Token Deep Dive (JWT)

An ID Token is a JWT (JSON Web Token) - a digitally signed envelope containing user claims.


JWT Structure: header.payload.signature





Standard OIDC Claims

Required Claims

Claim

Meaning

Example

sub

Subject (User ID)

"sub": "1234567890"

iss

Issuer (Who issued this)

"iss": "https://accounts.google.com"

aud

Audience (Intended recipient)

"aud": "123.apps.googleusercontent.com"

exp

Expiration Time

"exp": 1712349278 (Unix timestamp)

iat

Issued At

"iat": 1712345678 (Unix timestamp)

Common Identity Claims

Claim

Meaning

Example

name

Full name

"name": "John Doe"

given_name

First name

"given_name": "John"

family_name

Last name

"family_name": "Doe"

email

Email address

"email": "john@gmail.com"

email_verified

Is email confirmed?

"email_verified": true

picture

Profile photo URL

"picture": "https://lh3.googleusercontent.com/..."

locale

Language/Region

"locale": "en-US"

Security Claims

Claim

Meaning

Purpose

nonce

Random string

Prevents replay attacks

auth_time

When user authenticated

For session management

acr

Authentication Context

Level of assurance (e.g., "multi-factor")

amr

Authentication Methods

How user authenticated ("pwd", "mfa", "otp")

Complete Example: Decoded ID Token

{
  "sub": "109651783456723954281",
  "name": "John Doe",
  "given_name": "John",
  "family_name": "Doe",
  "picture": "https://lh3.googleusercontent.com/a-/abc123",
  "email": "john.doe@gmail.com",
  "email_verified": true,
  "locale": "en",
  "iss": "https://accounts.google.com",
  "aud": "1234567890-abcd1234.apps.googleusercontent.com",
  "iat": 1712345678,
  "exp": 1712349278,
  "auth_time": 1712345600,
  "nonce": "n-0S6_WzA2M"
}
{
  "sub": "109651783456723954281",
  "name": "John Doe",
  "given_name": "John",
  "family_name": "Doe",
  "picture": "https://lh3.googleusercontent.com/a-/abc123",
  "email": "john.doe@gmail.com",
  "email_verified": true,
  "locale": "en",
  "iss": "https://accounts.google.com",
  "aud": "1234567890-abcd1234.apps.googleusercontent.com",
  "iat": 1712345678,
  "exp": 1712349278,
  "auth_time": 1712345600,
  "nonce": "n-0S6_WzA2M"
}
{
  "sub": "109651783456723954281",
  "name": "John Doe",
  "given_name": "John",
  "family_name": "Doe",
  "picture": "https://lh3.googleusercontent.com/a-/abc123",
  "email": "john.doe@gmail.com",
  "email_verified": true,
  "locale": "en",
  "iss": "https://accounts.google.com",
  "aud": "1234567890-abcd1234.apps.googleusercontent.com",
  "iat": 1712345678,
  "exp": 1712349278,
  "auth_time": 1712345600,
  "nonce": "n-0S6_WzA2M"
}


Token Verification Checklist

Your backend MUST verify:

  1. Signature - Is this token really from Google/Provider?

    • Fetch public keys from JWKS endpoint

    • Verify JWT signature using the key

  2. Issuer (iss) - Did the right provider issue this?

    • Check against expected issuer URL

    • Prevents token substitution attacks

  3. Audience (aud) - Is this token meant for my app?

    • Verify matches your client ID

    • Prevents token reuse across apps

  4. Expiration (exp) - Is this token still valid?

    • Check against current time

    • Reject expired tokens

  5. Email Verification - Should I trust this email?

    • Check email_verified is true

    • Don't trust unverified emails for password resets


Practical Implementation

Scenario 1: Using Clerk (Managed)

Best for: Speed of development, built-in features, don't want to maintain auth infrastructure.

Frontend (React Native/Expo):

import { useSSO } from '@clerk/clerk-expo';

function AuthScreen() {
  const { startSSOFlow } = useSSO();
  
  const handleGoogleSignIn = async () => {
    const { createdSessionId, setActive } = await startSSOFlow({
      strategy: 'oauth_google',
    });
    
    if (createdSessionId) {
      await setActive({ session: createdSessionId });
    }
  };
}
import { useSSO } from '@clerk/clerk-expo';

function AuthScreen() {
  const { startSSOFlow } = useSSO();
  
  const handleGoogleSignIn = async () => {
    const { createdSessionId, setActive } = await startSSOFlow({
      strategy: 'oauth_google',
    });
    
    if (createdSessionId) {
      await setActive({ session: createdSessionId });
    }
  };
}
import { useSSO } from '@clerk/clerk-expo';

function AuthScreen() {
  const { startSSOFlow } = useSSO();
  
  const handleGoogleSignIn = async () => {
    const { createdSessionId, setActive } = await startSSOFlow({
      strategy: 'oauth_google',
    });
    
    if (createdSessionId) {
      await setActive({ session: createdSessionId });
    }
  };
}

Backend (FastAPI):

from clerk_backend_api import Clerk
from clerk_backend_api.jwks_helpers import AuthenticateRequestOptions

async def require_auth(request):
    sdk = Clerk(bearer_auth=os.getenv('CLERK_SECRET_KEY'))
    
    request_state = sdk.authenticate_request(
        request={'headers': dict(request.headers), 'url': str(request.url)},
        options=AuthenticateRequestOptions()
    )
    
    if not request_state.is_signed_in:
        raise HTTPException(status_code=401)
    
    return request_state.payload  # Contains 'sub' (user ID)
from clerk_backend_api import Clerk
from clerk_backend_api.jwks_helpers import AuthenticateRequestOptions

async def require_auth(request):
    sdk = Clerk(bearer_auth=os.getenv('CLERK_SECRET_KEY'))
    
    request_state = sdk.authenticate_request(
        request={'headers': dict(request.headers), 'url': str(request.url)},
        options=AuthenticateRequestOptions()
    )
    
    if not request_state.is_signed_in:
        raise HTTPException(status_code=401)
    
    return request_state.payload  # Contains 'sub' (user ID)
from clerk_backend_api import Clerk
from clerk_backend_api.jwks_helpers import AuthenticateRequestOptions

async def require_auth(request):
    sdk = Clerk(bearer_auth=os.getenv('CLERK_SECRET_KEY'))
    
    request_state = sdk.authenticate_request(
        request={'headers': dict(request.headers), 'url': str(request.url)},
        options=AuthenticateRequestOptions()
    )
    
    if not request_state.is_signed_in:
        raise HTTPException(status_code=401)
    
    return request_state.payload  # Contains 'sub' (user ID)

What Clerk handles for you:

  • OAuth/OIDC protocol implementation

  • Token storage (encrypted)

  • Token refresh

  • Session management

  • User creation in their database

  • Social provider configuration


Scenario 2: Using Zitadel (Self-Hosted OIDC)

Best for: Full control, data sovereignty, compliance requirements, cost savings at scale.

Step 1: Configure Zitadel

  • Create project

  • Create Native App application

  • Get Client ID

  • Note discovery URL: https://zitadel.company.com/.well-known/openid-configuration

Frontend (Expo):

import * as AuthSession from 'expo-auth-session';
import * as SecureStore from 'expo-secure-store';

const ZITADEL_DOMAIN = 'https://zitadel.company.com';
const CLIENT_ID = '123456789@project';

const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: CLIENT_ID,
    scopes: ['openid', 'profile', 'email'],
    redirectUri: AuthSession.makeRedirectUri({ scheme: 'myapp' }),
    usePKCE: true,  // REQUIRED for native apps
  },
  {
    authorizationEndpoint: `${ZITADEL_DOMAIN}/oauth/v2/authorize`,
    tokenEndpoint: `${ZITADEL_DOMAIN}/oauth/v2/token`,
  }
);

// Exchange code for tokens
const exchangeCode = async (code) => {
  const response = await fetch(`${ZITADEL_DOMAIN}/oauth/v2/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: CLIENT_ID,
      code,
      redirect_uri: redirectUri,
      code_verifier: request.codeVerifier,
    }),
  });
  
  const tokens = await response.json();
  // Store: tokens.id_token, tokens.access_token
};
import * as AuthSession from 'expo-auth-session';
import * as SecureStore from 'expo-secure-store';

const ZITADEL_DOMAIN = 'https://zitadel.company.com';
const CLIENT_ID = '123456789@project';

const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: CLIENT_ID,
    scopes: ['openid', 'profile', 'email'],
    redirectUri: AuthSession.makeRedirectUri({ scheme: 'myapp' }),
    usePKCE: true,  // REQUIRED for native apps
  },
  {
    authorizationEndpoint: `${ZITADEL_DOMAIN}/oauth/v2/authorize`,
    tokenEndpoint: `${ZITADEL_DOMAIN}/oauth/v2/token`,
  }
);

// Exchange code for tokens
const exchangeCode = async (code) => {
  const response = await fetch(`${ZITADEL_DOMAIN}/oauth/v2/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: CLIENT_ID,
      code,
      redirect_uri: redirectUri,
      code_verifier: request.codeVerifier,
    }),
  });
  
  const tokens = await response.json();
  // Store: tokens.id_token, tokens.access_token
};
import * as AuthSession from 'expo-auth-session';
import * as SecureStore from 'expo-secure-store';

const ZITADEL_DOMAIN = 'https://zitadel.company.com';
const CLIENT_ID = '123456789@project';

const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: CLIENT_ID,
    scopes: ['openid', 'profile', 'email'],
    redirectUri: AuthSession.makeRedirectUri({ scheme: 'myapp' }),
    usePKCE: true,  // REQUIRED for native apps
  },
  {
    authorizationEndpoint: `${ZITADEL_DOMAIN}/oauth/v2/authorize`,
    tokenEndpoint: `${ZITADEL_DOMAIN}/oauth/v2/token`,
  }
);

// Exchange code for tokens
const exchangeCode = async (code) => {
  const response = await fetch(`${ZITADEL_DOMAIN}/oauth/v2/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: CLIENT_ID,
      code,
      redirect_uri: redirectUri,
      code_verifier: request.codeVerifier,
    }),
  });
  
  const tokens = await response.json();
  // Store: tokens.id_token, tokens.access_token
};

Backend (Verify ID Token):

import jwt
import requests

# Fetch Zitadel's JWKS
jwks = requests.get('https://zitadel.company.com/oauth/v2/keys').json()

def verify_token(token):
    # Get key ID from token header
    unverified = jwt.get_unverified_header(token)
    key_id = unverified['kid']
    
    # Find matching public key
    for key in jwks['keys']:
        if key['kid'] == key_id:
            public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)
            break
    
    # Verify and decode
    payload = jwt.decode(
        token,
        public_key,
        algorithms=['RS256'],
        audience=CLIENT_ID,
        issuer='https://zitadel.company.com'
    )
    
    return payload  # Contains sub, email, name, etc.
import jwt
import requests

# Fetch Zitadel's JWKS
jwks = requests.get('https://zitadel.company.com/oauth/v2/keys').json()

def verify_token(token):
    # Get key ID from token header
    unverified = jwt.get_unverified_header(token)
    key_id = unverified['kid']
    
    # Find matching public key
    for key in jwks['keys']:
        if key['kid'] == key_id:
            public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)
            break
    
    # Verify and decode
    payload = jwt.decode(
        token,
        public_key,
        algorithms=['RS256'],
        audience=CLIENT_ID,
        issuer='https://zitadel.company.com'
    )
    
    return payload  # Contains sub, email, name, etc.
import jwt
import requests

# Fetch Zitadel's JWKS
jwks = requests.get('https://zitadel.company.com/oauth/v2/keys').json()

def verify_token(token):
    # Get key ID from token header
    unverified = jwt.get_unverified_header(token)
    key_id = unverified['kid']
    
    # Find matching public key
    for key in jwks['keys']:
        if key['kid'] == key_id:
            public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)
            break
    
    # Verify and decode
    payload = jwt.decode(
        token,
        public_key,
        algorithms=['RS256'],
        audience=CLIENT_ID,
        issuer='https://zitadel.company.com'
    )
    
    return payload  # Contains sub, email, name, etc.

What you handle yourself:

  • OIDC protocol implementation

  • Token storage

  • Token refresh

  • Session management

  • User database

  • Backend verification logic


Scenario 3: GitHub OAuth (Pure OAuth 2.0)

Important: GitHub does NOT implement OIDC. It's OAuth 2.0 only.

Frontend:

// Same OAuth flow as above
const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: CLIENT_ID,
    scopes: ['user:email'],  // OAuth scope, not OIDC
    redirectUri,
    usePKCE: true,
  },
  {
    authorizationEndpoint: 'https://github.com/login/oauth/authorize',
    tokenEndpoint: 'https://github.com/login/oauth/access_token',
  }
);
// Same OAuth flow as above
const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: CLIENT_ID,
    scopes: ['user:email'],  // OAuth scope, not OIDC
    redirectUri,
    usePKCE: true,
  },
  {
    authorizationEndpoint: 'https://github.com/login/oauth/authorize',
    tokenEndpoint: 'https://github.com/login/oauth/access_token',
  }
);
// Same OAuth flow as above
const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: CLIENT_ID,
    scopes: ['user:email'],  // OAuth scope, not OIDC
    redirectUri,
    usePKCE: true,
  },
  {
    authorizationEndpoint: 'https://github.com/login/oauth/authorize',
    tokenEndpoint: 'https://github.com/login/oauth/access_token',
  }
);

Critical Difference: You only get an access token, not an ID token!

You MUST make an extra API call:

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

const user = await userResponse.json();
// user = { login: "john", id: 12345, email: "john@example.com", ... }
// After getting access_token
const userResponse = await fetch('https://api.github.com/user', {
  headers: { 'Authorization': `Bearer ${access_token}` }
});

const user = await userResponse.json();
// user = { login: "john", id: 12345, email: "john@example.com", ... }
// After getting access_token
const userResponse = await fetch('https://api.github.com/user', {
  headers: { 'Authorization': `Bearer ${access_token}` }
});

const user = await userResponse.json();
// user = { login: "john", id: 12345, email: "john@example.com", ... }

This is the key difference:

  • OIDC providers (Google, Apple, Zitadel) give you identity in the token

  • OAuth-only providers (GitHub) require an extra API call to get identity


Mobile App Considerations

Why Mobile is Different

Challenge 1: No Web Server

  • Web apps have a backend server to receive callbacks

  • Mobile apps need custom URL schemes

Solution: Custom URL Schemes




Challenge 2: Can't Keep Secrets

  • Native apps can't securely store client secrets

  • Anyone can decompile your app and extract secrets

Solution: PKCE (Proof Key for Code Exchange)

  • Standard OAuth extension specifically for native apps

  • Uses a temporary secret generated per-login

  • Even if intercepted, the attacker can't use the authorization code

How PKCE Works:




Challenge 3: Secure Token Storage

  • Can't store tokens in localStorage (no browser)

  • AsyncStorage is not encrypted

Solution: expo-secure-store

import * as SecureStore from 'expo-secure-store';

// Store token
await SecureStore.setItemAsync('id_token', token);

// Retrieve token
const token = await SecureStore.getItemAsync('id_token');

// Delete token
await SecureStore.deleteItemAsync('id_token');
import * as SecureStore from 'expo-secure-store';

// Store token
await SecureStore.setItemAsync('id_token', token);

// Retrieve token
const token = await SecureStore.getItemAsync('id_token');

// Delete token
await SecureStore.deleteItemAsync('id_token');
import * as SecureStore from 'expo-secure-store';

// Store token
await SecureStore.setItemAsync('id_token', token);

// Retrieve token
const token = await SecureStore.getItemAsync('id_token');

// Delete token
await SecureStore.deleteItemAsync('id_token');

Mobile OIDC Flow with PKCE

import * as AuthSession from 'expo-auth-session';
import * as SecureStore from 'expo-secure-store';

const redirectUri = AuthSession.makeRedirectUri({
  scheme: 'myapp',
  path: 'callback'
});

const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: '123@project',
    scopes: ['openid', 'profile', 'email'],
    redirectUri,
    usePKCE: true,  // REQUIRED!
  },
  {
    authorizationEndpoint: 'https://provider.com/oauth/v2/authorize',
    tokenEndpoint: 'https://provider.com/oauth/v2/token',
  }
);

// The request object automatically:
// 1. Generates code_verifier
// 2. Creates code_challenge (hash of verifier)
// 3. Includes code_challenge in auth URL

// When exchanging code for tokens:
// AuthSession automatically includes code_verifier
import * as AuthSession from 'expo-auth-session';
import * as SecureStore from 'expo-secure-store';

const redirectUri = AuthSession.makeRedirectUri({
  scheme: 'myapp',
  path: 'callback'
});

const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: '123@project',
    scopes: ['openid', 'profile', 'email'],
    redirectUri,
    usePKCE: true,  // REQUIRED!
  },
  {
    authorizationEndpoint: 'https://provider.com/oauth/v2/authorize',
    tokenEndpoint: 'https://provider.com/oauth/v2/token',
  }
);

// The request object automatically:
// 1. Generates code_verifier
// 2. Creates code_challenge (hash of verifier)
// 3. Includes code_challenge in auth URL

// When exchanging code for tokens:
// AuthSession automatically includes code_verifier
import * as AuthSession from 'expo-auth-session';
import * as SecureStore from 'expo-secure-store';

const redirectUri = AuthSession.makeRedirectUri({
  scheme: 'myapp',
  path: 'callback'
});

const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: '123@project',
    scopes: ['openid', 'profile', 'email'],
    redirectUri,
    usePKCE: true,  // REQUIRED!
  },
  {
    authorizationEndpoint: 'https://provider.com/oauth/v2/authorize',
    tokenEndpoint: 'https://provider.com/oauth/v2/token',
  }
);

// The request object automatically:
// 1. Generates code_verifier
// 2. Creates code_challenge (hash of verifier)
// 3. Includes code_challenge in auth URL

// When exchanging code for tokens:
// AuthSession automatically includes code_verifier

Provider Comparison

Feature

Clerk

Zitadel

Auth0

Firebase Auth

DIY

Hosting

Managed

Self-hosted

Managed

Managed

Self-hosted

OIDC Protocol

Abstracted

Full implementation

Abstracted

Abstracted

You implement

Mobile SDK

Excellent

Manual

Good

Good

None

Social Login

Built-in

Manual config

Built-in

Limited

Manual

User Management

Built-in

Built-in

Built-in

Built-in

You build

Token Refresh

Automatic

Manual

Automatic

Automatic

You implement

Backend Verification

SDK

JWT library

SDK

SDK

JWT library

Cost Model

Per-user

Free/Self

Per-user

Free tier

Infrastructure


When to Choose Each

Choose Clerk when:

  • Speed of development is critical

  • You want built-in user analytics

  • You need teams/organizations feature

  • You don't want to maintain auth infrastructure

Choose Zitadel/Keycloak when:

  • Data sovereignty is required (GDPR, HIPAA)

  • Cost at scale matters (no per-user fees)

  • You need full control over the protocol

  • You want self-hosted for compliance

Choose Auth0/Firebase when:

  • You're already in that ecosystem

  • You want managed but not Clerk-specific features

  • You need specific integrations they offer

Choose DIY (implement OIDC yourself) when:

  • You have very specific requirements

  • You want to deeply understand the protocol

  • You're building an auth provider

  • Not recommended for production apps - security is hard


Common Confusions

1. "Is GitHub OIDC or OAuth?"

Answer: GitHub is OAuth 2.0 only (not OIDC).

Why people get confused:

  • GitHub has "Sign in with GitHub"

  • It returns user info

  • It FEELS like identity

The reality:

  • GitHub implements OAuth 2.0

  • To get identity, you must call GET /user API

  • They don't issue ID Tokens

OIDC providers: Google, Apple, Microsoft, Facebook, Zitadel, Keycloak, Auth0
OAuth-only: GitHub, Twitter (legacy), LinkedIn (legacy)


2. "Do I get API permissions in the ID Token?"

Answer: No. ID Token = Identity only. Access Token = API permissions.

Example with Google Maps:





3. "Does logout sign me out of Google?"

Answer: No.

What happens when you sign out:

  • Your app's session is cleared

  • Tokens are deleted from device

  • Server session is invalidated

What does NOT happen:

  • User stays signed into Google/Apple on their phone

  • Other apps using Google Sign-In stay signed in

This is correct behavior - each app has its own session. The identity provider (Google) session is separate.

4. "Can I use OIDC without OAuth?"

Answer: No - OIDC is built on top of OAuth 2.0.

Analogy:

  • OAuth 2.0 = Basic cable package

  • OIDC = Basic cable + HBO (identity layer on top)

In practice:

  • OIDC flow returns both ID Token (identity) AND Access Token (authorization)

  • They're siblings, delivered together

  • You use the right token for the right purpose

5. "Is my session token an OIDC token?"

Answer: Depends on your implementation.

Three layers of tokens:

  1. Provider Token (from Google/Apple/Zitadel)

    • ID Token: JWT with user identity

    • Access Token: For calling provider APIs

  2. Session Token (from your backend or Clerk)

    • Your app's session identifier

    • Could be JWT or opaque string

    • Used for YOUR API calls

  3. Refresh Token (from provider)

    • Gets new access/ID tokens

    • Long-lived, stored securely

Typical flow:





Security Best Practices

1. Always Verify Tokens Server-Side

Never trust tokens in frontend-only apps. Always verify on your backend:

# Backend verification checklist
def verify_token(token):
    # 1. Verify signature
    key = fetch_provider_public_key(token)
    jwt.decode(token, key, algorithms=['RS256'])
    
    # 2. Verify issuer
    if payload['iss'] not in ALLOWED_ISSUERS:
        raise "Invalid issuer"
    
    # 3. Verify audience
    if payload['aud'] != YOUR_CLIENT_ID:
        raise "Wrong audience"
    
    # 4. Verify expiration
    if payload['exp'] < current_time():
        raise "Token expired"
    
    return payload
# Backend verification checklist
def verify_token(token):
    # 1. Verify signature
    key = fetch_provider_public_key(token)
    jwt.decode(token, key, algorithms=['RS256'])
    
    # 2. Verify issuer
    if payload['iss'] not in ALLOWED_ISSUERS:
        raise "Invalid issuer"
    
    # 3. Verify audience
    if payload['aud'] != YOUR_CLIENT_ID:
        raise "Wrong audience"
    
    # 4. Verify expiration
    if payload['exp'] < current_time():
        raise "Token expired"
    
    return payload
# Backend verification checklist
def verify_token(token):
    # 1. Verify signature
    key = fetch_provider_public_key(token)
    jwt.decode(token, key, algorithms=['RS256'])
    
    # 2. Verify issuer
    if payload['iss'] not in ALLOWED_ISSUERS:
        raise "Invalid issuer"
    
    # 3. Verify audience
    if payload['aud'] != YOUR_CLIENT_ID:
        raise "Wrong audience"
    
    # 4. Verify expiration
    if payload['exp'] < current_time():
        raise "Token expired"
    
    return payload

2. Use PKCE for All Native Apps

PKCE is REQUIRED for mobile apps. Without it, intercepted authorization codes can be used by attackers.

// Always set usePKCE: true
const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: CLIENT_ID,
    scopes: ['openid', 'profile', 'email'],
    redirectUri,
    usePKCE: true,  // NEVER FALSE FOR MOBILE
  },
  // ...
);
// Always set usePKCE: true
const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: CLIENT_ID,
    scopes: ['openid', 'profile', 'email'],
    redirectUri,
    usePKCE: true,  // NEVER FALSE FOR MOBILE
  },
  // ...
);
// Always set usePKCE: true
const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: CLIENT_ID,
    scopes: ['openid', 'profile', 'email'],
    redirectUri,
    usePKCE: true,  // NEVER FALSE FOR MOBILE
  },
  // ...
);

3. Store Tokens Securely

// Good: Encrypted storage
import * as SecureStore from 'expo-secure-store';
await SecureStore.setItemAsync('token', value);

// Bad: Unencrypted storage
import AsyncStorage from '@react-native-async-storage/async-storage';
await AsyncStorage.setItem('token', value);  // DON'T DO THIS
// Good: Encrypted storage
import * as SecureStore from 'expo-secure-store';
await SecureStore.setItemAsync('token', value);

// Bad: Unencrypted storage
import AsyncStorage from '@react-native-async-storage/async-storage';
await AsyncStorage.setItem('token', value);  // DON'T DO THIS
// Good: Encrypted storage
import * as SecureStore from 'expo-secure-store';
await SecureStore.setItemAsync('token', value);

// Bad: Unencrypted storage
import AsyncStorage from '@react-native-async-storage/async-storage';
await AsyncStorage.setItem('token', value);  // DON'T DO THIS

4. Short-Lived Tokens + Refresh Tokens

  • Access tokens: 15 minutes to 1 hour

  • ID tokens: Same or shorter

  • Refresh tokens: Long-lived (days/months), rotatable

Why: If an access token is stolen, the window of attack is limited.

5. Validate Email Before Trusting

if not payload.get('email_verified'):
    # Don't use this email for password resets
    # Don't consider account fully verified
    # Maybe require additional verification
if not payload.get('email_verified'):
    # Don't use this email for password resets
    # Don't consider account fully verified
    # Maybe require additional verification
if not payload.get('email_verified'):
    # Don't use this email for password resets
    # Don't consider account fully verified
    # Maybe require additional verification

6. Use State Parameter (CSRF Protection)

// AuthSession handles this automatically
// But if implementing manually:
const state = generateRandomString(32);
// Store state in memory
// Include in auth URL
// Verify state matches when redirect returns
// AuthSession handles this automatically
// But if implementing manually:
const state = generateRandomString(32);
// Store state in memory
// Include in auth URL
// Verify state matches when redirect returns
// AuthSession handles this automatically
// But if implementing manually:
const state = generateRandomString(32);
// Store state in memory
// Include in auth URL
// Verify state matches when redirect returns

7. HTTPS Only

Never use HTTP for:

  • Authorization endpoints

  • Token endpoints

  • JWKS endpoints

  • Your callback URLs

8. Principle of Least Privilege (Scopes)

Only request scopes you need:

// Good: Minimal scopes
scopes: ['openid', 'profile', 'email']

// Bad: Overly broad
scopes: ['openid', 'profile', 'email', 'calendar', 'drive', 
         'photos', 'youtube', 'spreadsheets']

// Good: Minimal scopes
scopes: ['openid', 'profile', 'email']

// Bad: Overly broad
scopes: ['openid', 'profile', 'email', 'calendar', 'drive', 
         'photos', 'youtube', 'spreadsheets']

// Good: Minimal scopes
scopes: ['openid', 'profile', 'email']

// Bad: Overly broad
scopes: ['openid', 'profile', 'email', 'calendar', 'drive', 
         'photos', 'youtube', 'spreadsheets']


Summary Cheat Sheet

Question

OAuth 2.0

OIDC

What is it?

Permission/Authorization

Identity/Authentication

Token type

Access Token

ID Token (JWT)

Contains

Permission scopes

User claims (name, email, sub)

Used for

Calling APIs

Knowing who the user is

Example

"Post to my Twitter"

"This is John Doe"

Built on

Foundation layer

On top of OAuth 2.0


Remember:

  • OAuth = Can I borrow your car? (permission)

  • OIDC = Can I see your driver's license? (identity)

  • Mobile apps = Use PKCE always

  • Store tokens securely

  • Verify server-side

  • GitHub โ‰  OIDC, Google = OIDC

Further Reading