Expert assistance with OAuth 2.0 authorization framework and OpenID Connect.
Overview
OAuth 2.0 is an authorization framework that enables applications to obtain limited access to user accounts. Key concepts:
- Resource Owner: User who owns the data
- Client: Application requesting access
- Authorization Server: Issues access tokens (e.g., Keycloak, Auth0)
- Resource Server: API that holds protected resources
- Access Token: Credentials to access protected resources
- Refresh Token: Credentials to obtain new access tokens
OAuth 2.0 Flows
Authorization Code Flow (Most Secure)
Best for: Server-side web apps with backend
Flow:
1. Client redirects user to authorization server
GET /authorize?
response_type=code
&client_id=CLIENT_ID
&redirect_uri=REDIRECT_URI
&scope=read write
&state=RANDOM_STATE
2. User authenticates and grants permission
3. Authorization server redirects back with code
REDIRECT_URI?code=AUTH_CODE&state=RANDOM_STATE
4. Client exchanges code for tokens
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=REDIRECT_URI
&client_id=CLIENT_ID
&client_secret=CLIENT_SECRET
5. Authorization server responds with tokens
{
"access_token": "ACCESS_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "REFRESH_TOKEN",
"scope": "read write"
}
Implementation (Node.js):
// Step 1: Redirect to authorization
app.get('/login', (req, res) => {
const state = crypto.randomBytes(16).toString('hex')
req.session.oauthState = state
const authUrl = new URL('https://auth.example.com/authorize')
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('client_id', CLIENT_ID)
authUrl.searchParams.set('redirect_uri', REDIRECT_URI)
authUrl.searchParams.set('scope', 'read write')
authUrl.searchParams.set('state', state)
res.redirect(authUrl.toString())
})
// Step 3 & 4: Handle callback and exchange code
app.get('/callback', async (req, res) => {
const { code, state } = req.query
// Verify state
if (state !== req.session.oauthState) {
return res.status(400).send('Invalid state')
}
// Exchange code for token
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
})
const tokens = await tokenResponse.json()
// Store tokens securely
req.session.accessToken = tokens.access_token
req.session.refreshToken = tokens.refresh_token
res.redirect('/dashboard')
})
Authorization Code Flow with PKCE
Best for: Mobile apps, SPAs, any public client
PKCE adds security for public clients that can't keep secrets
Flow:
// Step 1: Generate code verifier and challenge
const codeVerifier = base64URLEncode(crypto.randomBytes(32))
const codeChallenge = base64URLEncode(
crypto.createHash('sha256').update(codeVerifier).digest()
)
// Step 2: Authorization request
GET /authorize?
response_type=code
&client_id=CLIENT_ID
&redirect_uri=REDIRECT_URI
&scope=read
&state=STATE
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
// Step 3: Token request (no client_secret needed)
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=REDIRECT_URI
&client_id=CLIENT_ID
&code_verifier=CODE_VERIFIER
Implementation (React):
import { useEffect } from 'react'
import { useRouter } from 'next/router'
// Generate PKCE challenge
function generatePKCE() {
const codeVerifier = generateRandomString(128)
const codeChallenge = base64URLEncode(
sha256(codeVerifier)
)
return { codeVerifier, codeChallenge }
}
function LoginButton() {
const router = useRouter()
const handleLogin = () => {
const { codeVerifier, codeChallenge } = generatePKCE()
const state = generateRandomString(32)
// Store for later use
sessionStorage.setItem('pkce_verifier', codeVerifier)
sessionStorage.setItem('oauth_state', state)
const authUrl = new URL('https://auth.example.com/authorize')
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('client_id', CLIENT_ID)
authUrl.searchParams.set('redirect_uri', REDIRECT_URI)
authUrl.searchParams.set('scope', 'openid profile email')
authUrl.searchParams.set('state', state)
authUrl.searchParams.set('code_challenge', codeChallenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
window.location.href = authUrl.toString()
}
return <button onClick={handleLogin}>Login</button>
}
// Callback page
function CallbackPage() {
const router = useRouter()
useEffect(() => {
async function handleCallback() {
const { code, state } = router.query
// Verify state
const savedState = sessionStorage.getItem('oauth_state')
if (state !== savedState) {
throw new Error('Invalid state')
}
// Get code verifier
const codeVerifier = sessionStorage.getItem('pkce_verifier')
// Exchange code for token
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code as string,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier!,
}),
})
const tokens = await response.json()
// Store tokens
localStorage.setItem('access_token', tokens.access_token)
localStorage.setItem('refresh_token', tokens.refresh_token)
// Clean up
sessionStorage.removeItem('pkce_verifier')
sessionStorage.removeItem('oauth_state')
router.push('/dashboard')
}
handleCallback()
}, [router.query])
return <div>Logging in...</div>
}
Client Credentials Flow
Best for: Machine-to-machine, service accounts, server-to-server
Flow:
# Request token
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=CLIENT_ID
&client_secret=CLIENT_SECRET
&scope=api.read api.write
# Response
{
"access_token": "ACCESS_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "api.read api.write"
}
Implementation:
async function getServiceToken() {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
scope: 'api.read api.write',
}),
})
return await response.json()
}
// Use token
async function callProtectedAPI() {
const { access_token } = await getServiceToken()
const response = await fetch('https://api.example.com/data', {
headers: {
'Authorization': `Bearer ${access_token}`,
},
})
return await response.json()
}
Resource Owner Password Credentials (Legacy)
⚠️ Not recommended - Only use when no other flow works
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=password
&username=USER
&password=PASSWORD
&client_id=CLIENT_ID
&client_secret=CLIENT_SECRET
&scope=read
Implicit Flow (Deprecated)
⚠️ Deprecated - Use Authorization Code Flow with PKCE instead
Token Management
Access Tokens
Characteristics:
- Short-lived (5-15 minutes typical)
- Used to access protected resources
- Should be treated as opaque strings
- Often JWT format but not required
Usage:
// Make API request with access token
fetch('https://api.example.com/user/profile', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
})
Refresh Tokens
Purpose: Obtain new access tokens without re-authentication
Usage:
async function refreshAccessToken(refreshToken) {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
})
const tokens = await response.json()
return tokens
}
// Automatic token refresh
async function apiCall(url, options = {}) {
let accessToken = localStorage.getItem('access_token')
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${accessToken}`,
},
})
// Token expired
if (response.status === 401) {
const refreshToken = localStorage.getItem('refresh_token')
const newTokens = await refreshAccessToken(refreshToken)
localStorage.setItem('access_token', newTokens.access_token)
if (newTokens.refresh_token) {
localStorage.setItem('refresh_token', newTokens.refresh_token)
}
// Retry request with new token
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newTokens.access_token}`,
},
})
}
return response
}
Token Storage
Best Practices:
Browser (SPA):
- ✅ Memory (most secure, lost on refresh)
- ✅ Session storage (cleared on tab close)
- ⚠️ Local storage (XSS risk, but convenient)
- ❌ Cookies without httpOnly (XSS risk)
- ✅ httpOnly cookies (CSRF protection needed)
Server-side:
- ✅ Encrypted session storage
- ✅ Secure database with encryption
- ❌ Plain text files
- ❌ Environment variables (for user tokens)
Implementation:
// Secure token storage (Next.js)
import { serialize, parse } from 'cookie'
// Set token in httpOnly cookie
export function setTokenCookie(res, token) {
const cookie = serialize('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 3600,
path: '/',
})
res.setHeader('Set-Cookie', cookie)
}
// Get token from cookie
export function getTokenCookie(req) {
const cookies = parse(req.headers.cookie || '')
return cookies.token
}
OpenID Connect (OIDC)
OAuth 2.0 extension for authentication
ID Token
JWT containing user information:
{
"iss": "https://auth.example.com",
"sub": "user-123",
"aud": "client-id",
"exp": 1234567890,
"iat": 1234567890,
"name": "John Doe",
"email": "john@example.com",
"email_verified": true,
"picture": "https://example.com/photo.jpg"
}
OIDC Flow
// Authorization request with openid scope
GET /authorize?
response_type=code
&client_id=CLIENT_ID
&redirect_uri=REDIRECT_URI
&scope=openid profile email
&state=STATE
// Token response includes ID token
{
"access_token": "ACCESS_TOKEN",
"id_token": "ID_TOKEN_JWT",
"refresh_token": "REFRESH_TOKEN",
"token_type": "Bearer",
"expires_in": 3600
}
// Validate ID token
import jwt from 'jsonwebtoken'
import jwksClient from 'jwks-rsa'
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json'
})
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
const signingKey = key.publicKey || key.rsaPublicKey
callback(null, signingKey)
})
}
jwt.verify(idToken, getKey, {
audience: CLIENT_ID,
issuer: 'https://auth.example.com',
algorithms: ['RS256']
}, (err, decoded) => {
if (err) {
console.error('Invalid token')
} else {
console.log('User:', decoded)
}
})
UserInfo Endpoint
// Get additional user info
async function getUserInfo(accessToken) {
const response = await fetch('https://auth.example.com/userinfo', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
})
return await response.json()
}
Discovery
# Get provider configuration
GET https://auth.example.com/.well-known/openid-configuration
# Response includes:
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"response_types_supported": ["code", "token", "id_token"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["client_secret_post"]
}
Scopes
Standard Scopes
openid- Required for OIDCprofile- User's profile info (name, picture, etc.)email- User's email addressaddress- User's addressphone- User's phone numberoffline_access- Request refresh token
Custom Scopes
API-specific permissions:
- api:read - Read access to API
- api:write - Write access to API
- api:delete - Delete access to API
- admin - Admin access
Requesting Scopes
// Request multiple scopes
const authUrl = new URL('https://auth.example.com/authorize')
authUrl.searchParams.set('scope', 'openid profile email api:read api:write')
Security Best Practices
1. Always Use State Parameter
// Generate random state
const state = crypto.randomBytes(32).toString('hex')
sessionStorage.setItem('oauth_state', state)
// Include in authorization request
authUrl.searchParams.set('state', state)
// Verify in callback
if (receivedState !== sessionStorage.getItem('oauth_state')) {
throw new Error('CSRF attack detected')
}
2. Use PKCE for Public Clients
Always use PKCE for SPAs and mobile apps - no exceptions.
3. Validate Tokens
// Validate JWT
- Verify signature using public key
- Check issuer (iss)
- Check audience (aud)
- Check expiration (exp)
- Check not before (nbf)
4. Short-Lived Access Tokens
Access token: 5-15 minutes
Refresh token: Days to months (with rotation)
5. Token Rotation
// Refresh token rotation
When using refresh token:
1. Issue new access token
2. Issue new refresh token
3. Invalidate old refresh token
6. Secure Token Storage
// ✅ Best practices
- httpOnly cookies (server-side)
- Encrypted session storage (server-side)
- Memory only (SPA, lost on refresh)
// ❌ Avoid
- Local storage (XSS vulnerable)
- Session storage without proper sanitization
- URL parameters
- Plain text anywhere
7. Redirect URI Validation
// Server must validate exact match
Registered: https://app.example.com/callback
Valid: https://app.example.com/callback
Invalid: https://app.example.com/callback/extra
Invalid: https://evil.com/callback
8. HTTPS Only
All OAuth communication must use HTTPS in production.
9. Rate Limiting
Implement rate limiting on:
- Token endpoint
- Authorization endpoint
- UserInfo endpoint
10. Audit Logging
Log all OAuth events:
- Authorization requests
- Token issuances
- Token refreshes
- Failed attempts
Common Patterns
API Protection
// Express middleware
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
return res.status(401).json({ error: 'No token provided' })
}
try {
const decoded = jwt.verify(token, PUBLIC_KEY)
req.user = decoded
next()
} catch (error) {
return res.status(401).json({ error: 'Invalid token' })
}
}
// Protected route
app.get('/api/protected', requireAuth, (req, res) => {
res.json({ data: 'Protected data', user: req.user })
})
// Role-based protection
function requireRole(role) {
return (req, res, next) => {
if (!req.user?.roles?.includes(role)) {
return res.status(403).json({ error: 'Insufficient permissions' })
}
next()
}
}
app.delete('/api/users/:id', requireAuth, requireRole('admin'), (req, res) => {
// Delete user
})
Silent Authentication
// Attempt silent authentication in hidden iframe
function silentAuthentication() {
return new Promise((resolve, reject) => {
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
const authUrl = new URL('https://auth.example.com/authorize')
authUrl.searchParams.set('prompt', 'none')
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('client_id', CLIENT_ID)
authUrl.searchParams.set('redirect_uri', SILENT_REDIRECT_URI)
iframe.src = authUrl.toString()
window.addEventListener('message', (event) => {
if (event.origin !== 'https://app.example.com') return
if (event.data.code) {
resolve(event.data.code)
} else {
reject(new Error('Silent auth failed'))
}
document.body.removeChild(iframe)
})
document.body.appendChild(iframe)
})
}
Troubleshooting
Invalid Grant
- Expired authorization code
- Code already used
- Mismatched redirect URI
- Invalid code verifier (PKCE)
Invalid Client
- Wrong client credentials
- Client not found
- Client disabled
Unauthorized Client
- Client not allowed for grant type
- Client not allowed for scope
Access Denied
- User denied permission
- Invalid scope requested
Resources
- RFC 6749 (OAuth 2.0): https://tools.ietf.org/html/rfc6749
- RFC 7636 (PKCE): https://tools.ietf.org/html/rfc7636
- OpenID Connect: https://openid.net/connect/
- OAuth 2.0 Security Best Practices: https://tools.ietf.org/html/draft-ietf-oauth-security-topics