OAuth 2.0 vs OIDC: Key Differences
Last updated
Last updated
When I first dove into the world of authentication and authorization protocols, I found myself confused by all the acronyms and standards. OAuth 2.0, OIDC, JWT, SAML... it was overwhelming. After years of implementing these protocols in production environments, I've come to appreciate the nuances between them, particularly OAuth 2.0 and OpenID Connect (OIDC). Let me share what I've learned through my own experiences.
I remember the exact moment when the difference between OAuth 2.0 and OIDC finally clicked for me. I was implementing authentication for a microservices architecture, and a senior architect asked me:
"Are you trying to let users access resources or are you trying to know who they are?"
That simple question illuminated everything:
OAuth 2.0 answers the question: "What can this application do on behalf of this user?" (Authorization)
OIDC answers the question: "Who is this user?" (Authentication)
In my early development days, I built a dashboard application that needed to access users' Google Calendar data without storing their Google passwords. This was a perfect use case for OAuth 2.0.
OAuth 2.0 is fundamentally about delegated authorization. It's a protocol that allows a user to grant a third-party application limited access to their resources without sharing their credentials. Think of it like a hotel key card system โ the key gives you access to specific areas without giving you ownership of the hotel.
The core OAuth 2.0 flow I use most often looks like this:
When implementing OAuth 2.0 with Keycloak, I focus on obtaining and using access tokens to request resources. The access token doesn't tell me much about the user โ it's mostly about what the user has authorized my application to do.
A few years later, I was building a SaaS platform that needed user profiles, role-based access, and single sign-on capabilities. That's when I turned to OpenID Connect.
OIDC extends OAuth 2.0 by adding a critical component: standardized user identity. It introduces the ID token, which contains verified claims about who the user is. In my mental model, OIDC takes OAuth 2.0 and says: "Great job handling authorization! Now let's add authentication too."
My typical OIDC flow looks like this:
The game-changer with OIDC is the ID token (typically a JWT) that contains standardized claims about the user โ their identifier, name, email, and other profile information. This token is signed, allowing my application to verify the user's identity locally without making additional API calls.
Let me share how I implement both protocols using Keycloak as my identity provider and Node.js for my applications.
First, I always configure Keycloak with both protocols in mind:
I create a dedicated realm for my application environment
I set up a client with these key settings:
Client ID: myapp
Client Protocol: openid-connect (supports both OAuth 2.0 and OIDC)
Access Type: confidential (for server applications) or public (for SPAs/mobile)
Standard Flow Enabled: ON (for authorization code flow)
Valid Redirect URIs: https://myapp.com/callback
Then I define roles and groups to model my application's permission structure
When I'm focused purely on authorization using OAuth 2.0, my implementation looks something like this:
// oauth2-example.js
const express = require('express');
const session = require('express-session');
const axios = require('axios');
const app = express();
// Configure session middleware
app.use(session({
secret: 'my-secret-key',
resave: false,
saveUninitialized: true
}));
// Keycloak OAuth 2.0 configuration
const keycloakConfig = {
authServerUrl: 'https://keycloak.mycompany.com/auth',
realm: 'my-realm',
clientId: 'my-client',
clientSecret: 'my-client-secret', // Only for confidential clients
redirectUri: 'http://localhost:3000/callback'
};
// Construct the OAuth endpoints
const authEndpoint = `${keycloakConfig.authServerUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth`;
const tokenEndpoint = `${keycloakConfig.authServerUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`;
// Step 1: Redirect user to the authorization endpoint
app.get('/login', (req, res) => {
// Store a random state value to prevent CSRF attacks
req.session.oauthState = Math.random().toString(36).substring(2);
// Build the authorization URL - notice specific OAuth 2.0 scope
const authUrl = new URL(authEndpoint);
authUrl.searchParams.append('client_id', keycloakConfig.clientId);
authUrl.searchParams.append('redirect_uri', keycloakConfig.redirectUri);
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('state', req.session.oauthState);
authUrl.searchParams.append('scope', 'calendar-read user-profile'); // Resource-specific scopes
res.redirect(authUrl.toString());
});
// Step 2: Handle the callback and exchange code for tokens
app.get('/callback', async (req, res) => {
// Verify state to prevent CSRF attacks
if (req.query.state !== req.session.oauthState) {
return res.status(403).send('Invalid state parameter');
}
try {
// Exchange authorization code for access token
const tokenResponse = await axios.post(tokenEndpoint, new URLSearchParams({
grant_type: 'authorization_code',
client_id: keycloakConfig.clientId,
client_secret: keycloakConfig.clientSecret,
code: req.query.code,
redirect_uri: keycloakConfig.redirectUri
}));
// Store tokens in session
req.session.tokens = {
access_token: tokenResponse.data.access_token,
refresh_token: tokenResponse.data.refresh_token
};
res.redirect('/calendar');
} catch (error) {
console.error('Token exchange failed:', error.response?.data || error);
res.status(500).send('Authentication failed');
}
});
// Step 3: Use access token to access protected resources
app.get('/calendar', async (req, res) => {
if (!req.session.tokens?.access_token) {
return res.redirect('/login');
}
try {
// Call a resource API using the access token
const calendarResponse = await axios.get('https://api.myapp.com/calendar', {
headers: { Authorization: `Bearer ${req.session.tokens.access_token}` }
});
res.send(`
<h1>Your Calendar Events</h1>
<pre>${JSON.stringify(calendarResponse.data, null, 2)}</pre>
<a href="/logout">Logout</a>
`);
} catch (error) {
if (error.response?.status === 401) {
// Token expired - could add refresh token logic here
return res.redirect('/login');
}
res.status(500).send('Error fetching calendar data');
}
});
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
In this OAuth 2.0 implementation, I'm primarily focused on obtaining an access token to call APIs. I don't extract or validate user identity from the tokens - I just pass them along to resource servers.
When I need authentication and user identity, my OIDC implementation looks like this:
// oidc-example.js
const express = require('express');
const session = require('express-session');
const axios = require('axios');
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const app = express();
app.use(session({
secret: 'my-secret-key',
resave: false,
saveUninitialized: true
}));
// Keycloak OIDC configuration
const keycloakConfig = {
authServerUrl: 'https://keycloak.mycompany.com/auth',
realm: 'my-realm',
clientId: 'my-client',
clientSecret: 'my-client-secret',
redirectUri: 'http://localhost:3000/callback'
};
// Construct the OIDC endpoints
const authEndpoint = `${keycloakConfig.authServerUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth`;
const tokenEndpoint = `${keycloakConfig.authServerUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`;
const jwksUri = `${keycloakConfig.authServerUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/certs`;
// Set up JWKS client for ID token validation
const jwks = jwksClient({
jwksUri: jwksUri
});
// Helper function to verify ID token
async function verifyIdToken(idToken) {
// Decode the token header to get the key ID
const decoded = jwt.decode(idToken, { complete: true });
if (!decoded || !decoded.header || !decoded.header.kid) {
throw new Error('Invalid token');
}
// Get the signing key from Keycloak's JWKS
const key = await jwks.getSigningKey(decoded.header.kid);
const signingKey = key.publicKey || key.rsaPublicKey;
// Verify the token
return jwt.verify(idToken, signingKey, {
audience: keycloakConfig.clientId,
issuer: `${keycloakConfig.authServerUrl}/realms/${keycloakConfig.realm}`
});
}
// Step 1: Redirect user to the authentication endpoint
app.get('/login', (req, res) => {
req.session.oidcState = Math.random().toString(36).substring(2);
req.session.oidcNonce = Math.random().toString(36).substring(2);
// Build the authorization URL - notice OIDC-specific scopes
const authUrl = new URL(authEndpoint);
authUrl.searchParams.append('client_id', keycloakConfig.clientId);
authUrl.searchParams.append('redirect_uri', keycloakConfig.redirectUri);
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('state', req.session.oidcState);
authUrl.searchParams.append('nonce', req.session.oidcNonce); // OIDC specific!
authUrl.searchParams.append('scope', 'openid profile email'); // OIDC scopes
res.redirect(authUrl.toString());
});
// Step 2: Handle the callback and process the ID token
app.get('/callback', async (req, res) => {
if (req.query.state !== req.session.oidcState) {
return res.status(403).send('Invalid state parameter');
}
try {
// Exchange code for tokens
const tokenResponse = await axios.post(tokenEndpoint, new URLSearchParams({
grant_type: 'authorization_code',
client_id: keycloakConfig.clientId,
client_secret: keycloakConfig.clientSecret,
code: req.query.code,
redirect_uri: keycloakConfig.redirectUri
}));
// Get all tokens - note the ID token which is OIDC-specific
const { access_token, refresh_token, id_token } = tokenResponse.data;
// Verify and decode the ID token
const userInfo = await verifyIdToken(id_token);
// Verify the nonce to prevent replay attacks (OIDC specific)
if (userInfo.nonce !== req.session.oidcNonce) {
return res.status(403).send('Invalid nonce');
}
// Store user info and tokens in session
req.session.user = {
sub: userInfo.sub,
name: userInfo.name,
email: userInfo.email,
preferred_username: userInfo.preferred_username
};
req.session.tokens = { access_token, refresh_token, id_token };
res.redirect('/dashboard');
} catch (error) {
console.error('Authentication failed:', error);
res.status(500).send('Authentication failed');
}
});
// Step 3: Display user information from the ID token
app.get('/dashboard', (req, res) => {
if (!req.session.user) {
return res.redirect('/login');
}
res.send(`
<h1>Welcome, ${req.session.user.name}!</h1>
<p>Email: ${req.session.user.email}</p>
<p>Username: ${req.session.user.preferred_username}</p>
<p>User ID: ${req.session.user.sub}</p>
<a href="/profile">View Profile API Data</a>
<br><br>
<a href="/logout">Logout</a>
`);
});
// Step 4: Use the access token to call APIs (same as OAuth 2.0)
app.get('/profile', async (req, res) => {
if (!req.session.tokens?.access_token) {
return res.redirect('/login');
}
try {
const profileResponse = await axios.get('https://api.myapp.com/profile', {
headers: { Authorization: `Bearer ${req.session.tokens.access_token}` }
});
res.send(`
<h1>Your Profile Data</h1>
<pre>${JSON.stringify(profileResponse.data, null, 2)}</pre>
<a href="/dashboard">Back to Dashboard</a>
`);
} catch (error) {
res.status(500).send('Error fetching profile data');
}
});
app.get('/logout', (req, res) => {
req.session.destroy();
res.redirect('/');
});
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
In my OIDC implementation, I'm not only obtaining an access token but also:
Requesting and validating an ID token
Verifying the token signature using Keycloak's public keys
Checking the nonce value to prevent replay attacks
Extracting standardized user information
Building a user session based on verified identity claims
After implementing both protocols across dozens of projects, here are the key differences I've observed:
OAuth 2.0:
Focused on getting permission to access resources
Primarily concerned with "what can be accessed"
Provides access tokens for API authorization
Doesn't standardize user information
OIDC:
Focused on establishing user identity
Primarily concerned with "who is accessing"
Provides ID tokens containing verified user information
Still includes access tokens for API authorization
In my OAuth 2.0 implementations, I work primarily with:
Access Token: Used to access resources, contains permissions (scopes)
Refresh Token: Used to get new access tokens when they expire
In my OIDC implementations, I work with both of the above, plus:
ID Token: Contains verified claims about the user (sub, name, email, etc.)
When implementing OIDC, I've noticed these additional parameters:
nonce: Prevents replay attacks with ID tokens
openid scope: Required to engage the OIDC protocol
Additional standard scopes: profile, email, address, phone
One distinction that tripped me up initially:
In OAuth 2.0, scopes define what actions can be performed (e.g., calendar.read)
In OIDC, claims are pieces of user information (e.g., name, email)
In my work, I choose between them based on the primary need:
I Choose OAuth 2.0 When:
I need API authorization between services
User identity isn't required
The focus is on machine-to-machine permissions
I Choose OIDC When:
I need user login functionality
I need verified user identity information
I'm building user-facing applications with profiles
After years of implementing both protocols, here's my simple decision tree:
Do you need to know who the user is?
Yes โ Use OIDC
No, I just need permission to access resources โ Use OAuth 2.0
Are you building a user-facing application with login?
Yes โ Use OIDC
No, it's a backend service or API โ OAuth 2.0 is sufficient
Do you need standardized user profile information?
Yes โ Use OIDC
No โ OAuth 2.0 is sufficient
In my real-world implementations, I've found that OAuth 2.0 and OIDC aren't competing standardsโthey're complementary. OIDC builds on OAuth 2.0 to create a complete identity solution.
The beauty is that with modern identity providers like Keycloak, you don't have to choose one or the other. When you configure your Keycloak client as an "openid-connect" client, you get the best of both worlds:
Authentication via ID tokens (OIDC)
Authorization via access tokens (OAuth 2.0)
I've found that most modern web applications benefit from using both protocols together. Start with OIDC for authentication and user identity, then use the same access tokens for OAuth 2.0-style API authorization.
Having implemented both protocols in production systems, I've come to appreciate how they work together to solve the complete identity and access management puzzle.