Deep Dive: OAuth 2.0 Client Credentials Grant
What is the OAuth 2.0 Client Credentials Grant?
OAuth 2.0 is a delegation framework — it lets a user (resource owner) grant a third-party application access to their resources without sharing credentials. But one OAuth 2.0 flow has nothing to do with users at all: the Client Credentials Grant (RFC 6749, Section 4.4).
In this flow, a service authenticates as itself — not on behalf of any user — using its own client_id and client_secret. The authorization server validates these credentials and issues a short-lived access token. The service then presents this token to downstream APIs, which verify the token’s signature and check its scopes before granting access.
This is the M2M (machine-to-machine) profile of OAuth 2.0, and it’s the industry standard for service-to-service authentication when you already have an identity provider in your stack.
Core Principles
- Client identity: Services are “clients” in OAuth terminology — each service gets a unique
client_idandclient_secret. - Short-lived tokens: Access tokens expire (typically 1-24 hours), limiting the blast radius of token leaks.
- Scope-based authorization: Each token carries scopes that define what the bearer can do (
payments:write,inventory:read). - Audience validation: The
audclaim restricts which services can accept a given token. - Bearer tokens: Tokens are self-contained (JWT) or opaque; possession grants access — no additional secret needed to use them.
- Stateless verification: JWT tokens can be verified by any service with the authorization server’s public key.
Authentication Flow
sequenceDiagram
participant OS as Order Service
participant AS as Auth Server (Keycloak/Auth0)
participant PS as Payment Service
Note over OS: Needs to call Payment Service
OS->>AS: POST /oauth/token
Note right of OS: client_id + client_secret + grant_type=client_credentials + scope=payments:write
AS->>AS: Validate client credentials
AS->>AS: Issue JWT access token (1h TTL)
AS-->>OS: {access_token, token_type: "Bearer", expires_in: 3600, scope: "payments:write"}
Note over OS: Cache token for up to expires_in seconds
OS->>PS: POST /api/payments/charge
Note right of OS: Authorization: Bearer eyJhbG...
PS->>PS: Verify JWT signature (offline using JWKS)
PS->>PS: Check scope includes "payments:write"
PS->>PS: Check aud matches "payment-service"
PS-->>OS: 200 OK — Charge processed
Note over OS: Token approaching expiry — refresh proactively
OS->>AS: POST /oauth/token (new request)
AS-->>OS: New access token
Part 1: Setting Up an Authorization Server
Keycloak (Self-Hosted)
Keycloak is the most popular open-source identity provider for on-premises deployments:
# Run Keycloak locally
docker run -p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:24.0.0 start-dev
Configure via Keycloak Admin UI or API:
#!/bin/bash
# keycloak-setup.sh — Configure realm and service clients via Keycloak Admin API
KEYCLOAK_URL="http://localhost:8080"
REALM="mycompany"
ADMIN_TOKEN=$(curl -s -X POST \
"${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=admin-cli&client_secret=admin&grant_type=password&username=admin&password=admin" \
| jq -r '.access_token')
# Create realm
curl -s -X POST "${KEYCLOAK_URL}/admin/realms" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"realm":"mycompany","enabled":true,"accessTokenLifespan":3600}'
# Create "order-service" client
curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"clientId": "order-service",
"secret": "order-service-secret-change-in-prod",
"serviceAccountsEnabled": true,
"publicClient": false,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"attributes": {"access.token.lifespan": "3600"}
}'
echo "Setup complete"
Auth0 (Cloud-Managed)
Auth0 simplifies M2M setup:
# Create an M2M Application via Auth0 Management API
curl -X POST https://${AUTH0_DOMAIN}/api/v2/clients \
-H "Authorization: Bearer ${MANAGEMENT_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"name": "order-service",
"app_type": "non_interactive",
"grant_types": ["client_credentials"]
}'
# Authorize the application for an API
curl -X POST https://${AUTH0_DOMAIN}/api/v2/client-grants \
-H "Authorization: Bearer ${MANAGEMENT_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"audience": "https://payment-service.internal",
"scope": ["payments:write", "payments:read"]
}'
Part 2: Token Fetching with Caching
The most important implementation detail for client credentials is token caching. Fetching a new token on every request adds 50-200ms of latency and hammers your authorization server. Tokens should be cached until near expiry, then refreshed proactively:
import axios from 'axios';
// ============================================================================
// Token Cache Entry
// ============================================================================
interface TokenEntry {
accessToken: string;
expiresAt: number; // Unix timestamp in milliseconds
scope: string;
}
// ============================================================================
// OAuth2 Client Credentials Manager
// ============================================================================
interface OAuth2Config {
tokenUrl: string; // e.g. https://keycloak/realms/mycompany/protocol/openid-connect/token
clientId: string;
clientSecret: string;
scope: string;
audience?: string; // Required for Auth0
refreshThresholdSeconds?: number; // Refresh this many seconds before expiry (default: 60)
}
class OAuth2ClientCredentials {
private config: OAuth2Config;
private tokenCache = new Map<string, TokenEntry>();
private inflightRequests = new Map<string, Promise<TokenEntry>>();
private refreshThreshold: number;
constructor(config: OAuth2Config) {
this.config = config;
this.refreshThreshold = (config.refreshThresholdSeconds ?? 60) * 1000;
}
// Cache key includes scope + audience for multi-scope clients
private cacheKey(): string {
return `${this.config.clientId}:${this.config.scope}:${this.config.audience ?? ''}`;
}
// Check if current cached token is still valid
private isTokenValid(entry: TokenEntry): boolean {
return Date.now() < entry.expiresAt - this.refreshThreshold;
}
// Fetch a fresh token from the authorization server
private async fetchToken(): Promise<TokenEntry> {
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
scope: this.config.scope,
});
if (this.config.audience) {
params.set('audience', this.config.audience);
}
const response = await axios.post<{
access_token: string;
expires_in: number;
token_type: string;
scope: string;
}>(this.config.tokenUrl, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 5000,
});
const { access_token, expires_in } = response.data;
return {
accessToken: access_token,
// expires_in is in seconds; subtract threshold for early refresh
expiresAt: Date.now() + expires_in * 1000,
scope: response.data.scope,
};
}
// Get a valid token, using the cache or fetching a new one
// Uses a single-flight pattern to prevent concurrent fetch storms
async getAccessToken(): Promise<string> {
const key = this.cacheKey();
// Return cached token if valid
const cached = this.tokenCache.get(key);
if (cached && this.isTokenValid(cached)) {
return cached.accessToken;
}
// If there's already a fetch in flight, wait for it
const inflight = this.inflightRequests.get(key);
if (inflight) {
const entry = await inflight;
return entry.accessToken;
}
// Start a new fetch, record it as inflight
const fetchPromise = this.fetchToken().then((entry) => {
this.tokenCache.set(key, entry);
this.inflightRequests.delete(key);
return entry;
}).catch((error) => {
this.inflightRequests.delete(key);
throw error;
});
this.inflightRequests.set(key, fetchPromise);
const entry = await fetchPromise;
return entry.accessToken;
}
// Explicitly invalidate the cache (e.g., on 401 response)
invalidate(): void {
this.tokenCache.delete(this.cacheKey());
}
}
// ============================================================================
// Multi-Service Token Manager
// ============================================================================
// Manage tokens for calls to different services (each may have different scopes)
class ServiceTokenManager {
private clients = new Map<string, OAuth2ClientCredentials>();
private baseConfig: Omit<OAuth2Config, 'scope' | 'audience'>;
constructor(baseConfig: Omit<OAuth2Config, 'scope' | 'audience'>) {
this.baseConfig = baseConfig;
}
getClient(scope: string, audience?: string): OAuth2ClientCredentials {
const key = `${scope}:${audience ?? ''}`;
if (!this.clients.has(key)) {
this.clients.set(key, new OAuth2ClientCredentials({
...this.baseConfig,
scope,
audience,
}));
}
return this.clients.get(key)!;
}
async getToken(scope: string, audience?: string): Promise<string> {
return this.getClient(scope, audience).getAccessToken();
}
}import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.*;
// ============================================================================
// Token Cache Entry
// ============================================================================
public record TokenEntry(String accessToken, long expiresAtMs, String scope) {}
// ============================================================================
// OAuth2 Client Credentials Manager
// ============================================================================
public record OAuth2Config(
String tokenUrl,
String clientId,
String clientSecret,
String scope,
String audience, // Required for Auth0 (nullable)
int refreshThresholdSeconds // default: 60
) {}
@Service
public class OAuth2ClientCredentials {
private final OAuth2Config config;
private final RestClient restClient;
private final long refreshThresholdMs;
private final ConcurrentHashMap<String, TokenEntry> tokenCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, CompletableFuture<TokenEntry>> inflightRequests =
new ConcurrentHashMap<>();
public OAuth2ClientCredentials(OAuth2Config config) {
this.config = config;
this.refreshThresholdMs = (long) config.refreshThresholdSeconds() * 1000;
this.restClient = RestClient.create();
}
// Cache key includes scope + audience for multi-scope clients
private String cacheKey() {
return config.clientId() + ":" + config.scope() + ":"
+ Optional.ofNullable(config.audience()).orElse("");
}
private boolean isTokenValid(TokenEntry entry) {
return System.currentTimeMillis() < entry.expiresAtMs() - refreshThresholdMs;
}
private TokenEntry fetchToken() {
var params = new LinkedMultiValueMap<String, String>();
params.add("grant_type", "client_credentials");
params.add("client_id", config.clientId());
params.add("client_secret", config.clientSecret());
params.add("scope", config.scope());
if (config.audience() != null) params.add("audience", config.audience());
var response = restClient.post()
.uri(config.tokenUrl())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(params)
.retrieve()
.body(Map.class);
String accessToken = (String) response.get("access_token");
int expiresIn = (Integer) response.get("expires_in");
String scope = (String) response.getOrDefault("scope", config.scope());
return new TokenEntry(
accessToken,
System.currentTimeMillis() + expiresIn * 1000L,
scope
);
}
// Get a valid token — single-flight pattern prevents concurrent fetch storms
public synchronized String getAccessToken() {
String key = cacheKey();
TokenEntry cached = tokenCache.get(key);
if (cached != null && isTokenValid(cached)) return cached.accessToken();
// Check inflight
CompletableFuture<TokenEntry> inflight = inflightRequests.get(key);
if (inflight != null) {
try { return inflight.get().accessToken(); }
catch (Exception e) { throw new RuntimeException(e); }
}
CompletableFuture<TokenEntry> future = CompletableFuture.supplyAsync(() -> {
TokenEntry entry = fetchToken();
tokenCache.put(key, entry);
inflightRequests.remove(key);
return entry;
});
inflightRequests.put(key, future);
try {
return future.get().accessToken();
} catch (Exception e) {
inflightRequests.remove(key);
throw new RuntimeException("Failed to fetch access token", e);
}
}
public void invalidate() {
tokenCache.remove(cacheKey());
}
}
// ============================================================================
// Multi-Service Token Manager
// ============================================================================
@Service
public class ServiceTokenManager {
private final ConcurrentHashMap<String, OAuth2ClientCredentials> clients =
new ConcurrentHashMap<>();
private final OAuth2Config baseConfig;
public ServiceTokenManager(OAuth2Config baseConfig) {
this.baseConfig = baseConfig;
}
public OAuth2ClientCredentials getClient(String scope, String audience) {
String key = scope + ":" + Optional.ofNullable(audience).orElse("");
return clients.computeIfAbsent(key, k -> new OAuth2ClientCredentials(
new OAuth2Config(baseConfig.tokenUrl(), baseConfig.clientId(),
baseConfig.clientSecret(), scope, audience,
baseConfig.refreshThresholdSeconds())
));
}
public String getToken(String scope, String audience) {
return getClient(scope, audience).getAccessToken();
}
}import asyncio
import time
import aiohttp
from dataclasses import dataclass, field
from typing import Optional
# ============================================================================
# Token Cache Entry
# ============================================================================
@dataclass
class TokenEntry:
access_token: str
expires_at_ms: int # Unix timestamp in milliseconds
scope: str
# ============================================================================
# OAuth2 Client Credentials Manager
# ============================================================================
@dataclass
class OAuth2Config:
token_url: str
client_id: str
client_secret: str
scope: str
audience: Optional[str] = None
refresh_threshold_seconds: int = 60 # Refresh before expiry
class OAuth2ClientCredentials:
def __init__(self, config: OAuth2Config):
self.config = config
self._refresh_threshold_ms = config.refresh_threshold_seconds * 1000
self._token_cache: dict[str, TokenEntry] = {}
self._inflight: dict[str, asyncio.Future] = {}
self._lock = asyncio.Lock()
def _cache_key(self) -> str:
return f"{self.config.client_id}:{self.config.scope}:{self.config.audience or ''}"
def _is_token_valid(self, entry: TokenEntry) -> bool:
return int(time.time() * 1000) < entry.expires_at_ms - self._refresh_threshold_ms
async def _fetch_token(self) -> TokenEntry:
params = {
"grant_type": "client_credentials",
"client_id": self.config.client_id,
"client_secret": self.config.client_secret,
"scope": self.config.scope,
}
if self.config.audience:
params["audience"] = self.config.audience
async with aiohttp.ClientSession() as session:
async with session.post(
self.config.token_url,
data=params,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
resp.raise_for_status()
data = await resp.json()
return TokenEntry(
access_token=data["access_token"],
# expires_in is in seconds; convert to ms
expires_at_ms=int(time.time() * 1000) + data["expires_in"] * 1000,
scope=data.get("scope", self.config.scope),
)
# Get a valid token — single-flight pattern prevents concurrent fetch storms
async def get_access_token(self) -> str:
key = self._cache_key()
# Return cached token if valid
cached = self._token_cache.get(key)
if cached and self._is_token_valid(cached):
return cached.access_token
async with self._lock:
# Double-check after acquiring lock
cached = self._token_cache.get(key)
if cached and self._is_token_valid(cached):
return cached.access_token
# If inflight, wait for it
if key in self._inflight:
entry = await self._inflight[key]
return entry.access_token
# Start a new fetch
future: asyncio.Future = asyncio.get_event_loop().create_future()
self._inflight[key] = future
try:
entry = await self._fetch_token()
self._token_cache[key] = entry
future.set_result(entry)
return entry.access_token
except Exception as e:
future.set_exception(e)
raise
finally:
self._inflight.pop(key, None)
def invalidate(self) -> None:
self._token_cache.pop(self._cache_key(), None)
# ============================================================================
# Multi-Service Token Manager
# ============================================================================
class ServiceTokenManager:
def __init__(self, base_config: OAuth2Config):
self._base_config = base_config
self._clients: dict[str, OAuth2ClientCredentials] = {}
def get_client(
self, scope: str, audience: Optional[str] = None
) -> OAuth2ClientCredentials:
key = f"{scope}:{audience or ''}"
if key not in self._clients:
self._clients[key] = OAuth2ClientCredentials(
OAuth2Config(
token_url=self._base_config.token_url,
client_id=self._base_config.client_id,
client_secret=self._base_config.client_secret,
scope=scope,
audience=audience,
)
)
return self._clients[key]
async def get_token(self, scope: str, audience: Optional[str] = None) -> str:
return await self.get_client(scope, audience).get_access_token()using System.Collections.Concurrent;
using System.Net.Http.Json;
// ============================================================================
// Token Cache Entry
// ============================================================================
public record TokenEntry(string AccessToken, long ExpiresAtMs, string Scope);
// ============================================================================
// OAuth2 Client Credentials Manager
// ============================================================================
public record OAuth2Config(
string TokenUrl,
string ClientId,
string ClientSecret,
string Scope,
string? Audience = null,
int RefreshThresholdSeconds = 60 // Refresh before expiry
);
public class OAuth2ClientCredentials
{
private readonly OAuth2Config _config;
private readonly HttpClient _httpClient;
private readonly long _refreshThresholdMs;
private readonly ConcurrentDictionary<string, TokenEntry> _tokenCache = new();
private readonly ConcurrentDictionary<string, Task<TokenEntry>> _inflightRequests = new();
private readonly SemaphoreSlim _lock = new(1, 1);
public OAuth2ClientCredentials(OAuth2Config config, HttpClient httpClient)
{
_config = config;
_httpClient = httpClient;
_refreshThresholdMs = config.RefreshThresholdSeconds * 1000L;
}
private string CacheKey() =>
$"{_config.ClientId}:{_config.Scope}:{_config.Audience ?? ""}";
private bool IsTokenValid(TokenEntry entry) =>
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() < entry.ExpiresAtMs - _refreshThresholdMs;
private async Task<TokenEntry> FetchTokenAsync()
{
var formData = new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = _config.ClientId,
["client_secret"] = _config.ClientSecret,
["scope"] = _config.Scope,
};
if (_config.Audience != null)
formData["audience"] = _config.Audience;
var response = await _httpClient.PostAsync(
_config.TokenUrl, new FormUrlEncodedContent(formData));
response.EnsureSuccessStatusCode();
var data = await response.Content.ReadFromJsonAsync<JsonElement>();
var accessToken = data.GetProperty("access_token").GetString()!;
var expiresIn = data.GetProperty("expires_in").GetInt32();
var scope = data.TryGetProperty("scope", out var s) ? s.GetString()! : _config.Scope;
return new TokenEntry(
accessToken,
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + expiresIn * 1000L,
scope
);
}
// Get a valid token — single-flight pattern prevents concurrent fetch storms
public async Task<string> GetAccessTokenAsync()
{
var key = CacheKey();
if (_tokenCache.TryGetValue(key, out var cached) && IsTokenValid(cached))
return cached.AccessToken;
await _lock.WaitAsync();
try
{
// Double-check after acquiring lock
if (_tokenCache.TryGetValue(key, out cached) && IsTokenValid(cached))
return cached.AccessToken;
if (_inflightRequests.TryGetValue(key, out var inflight))
return (await inflight).AccessToken;
var fetchTask = FetchTokenAsync().ContinueWith(t =>
{
_inflightRequests.TryRemove(key, out _);
if (t.IsCompletedSuccessfully)
_tokenCache[key] = t.Result;
return t.Result;
});
_inflightRequests[key] = fetchTask;
var entry = await fetchTask;
return entry.AccessToken;
}
finally
{
_lock.Release();
}
}
public void Invalidate() => _tokenCache.TryRemove(CacheKey(), out _);
}
// ============================================================================
// Multi-Service Token Manager
// ============================================================================
public class ServiceTokenManager
{
private readonly ConcurrentDictionary<string, OAuth2ClientCredentials> _clients = new();
private readonly OAuth2Config _baseConfig;
private readonly HttpClient _httpClient;
public ServiceTokenManager(OAuth2Config baseConfig, HttpClient httpClient)
{
_baseConfig = baseConfig;
_httpClient = httpClient;
}
public OAuth2ClientCredentials GetClient(string scope, string? audience = null)
{
var key = $"{scope}:{audience ?? ""}";
return _clients.GetOrAdd(key, _ => new OAuth2ClientCredentials(
_baseConfig with { Scope = scope, Audience = audience },
_httpClient
));
}
public Task<string> GetTokenAsync(string scope, string? audience = null) =>
GetClient(scope, audience).GetAccessTokenAsync();
}Part 3: HTTP Client with Automatic Token Injection
import axios, { AxiosInstance, AxiosError } from 'axios';
// ============================================================================
// OAuth2-authenticated HTTP Client
// ============================================================================
interface OAuth2HttpClientConfig {
baseURL: string;
tokenManager: ServiceTokenManager;
scope: string;
audience?: string;
timeout?: number;
}
class OAuth2HttpClient {
private client: AxiosInstance;
private tokenManager: ServiceTokenManager;
private scope: string;
private audience?: string;
constructor(config: OAuth2HttpClientConfig) {
this.tokenManager = config.tokenManager;
this.scope = config.scope;
this.audience = config.audience;
this.client = axios.create({
baseURL: config.baseURL,
timeout: config.timeout ?? 5000,
});
// Request interceptor: inject Bearer token
this.client.interceptors.request.use(async (requestConfig) => {
const token = await this.tokenManager.getToken(this.scope, this.audience);
requestConfig.headers.Authorization = `Bearer ${token}`;
return requestConfig;
});
// Response interceptor: handle 401 by refreshing token and retrying once
this.client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as any;
if (error.response?.status === 401 && !originalRequest._retried) {
originalRequest._retried = true;
// Invalidate cached token — it may have been revoked or expired early
this.tokenManager.getClient(this.scope, this.audience).invalidate();
// Fetch a fresh token and retry
const newToken = await this.tokenManager.getToken(this.scope, this.audience);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return this.client.request(originalRequest);
}
return Promise.reject(error);
}
);
}
async post<T>(path: string, data: unknown): Promise<T> {
const response = await this.client.post<T>(path, data);
return response.data;
}
async get<T>(path: string, params?: Record<string, string>): Promise<T> {
const response = await this.client.get<T>(path, { params });
return response.data;
}
}
// ============================================================================
// Wire Everything Together (Order Service)
// ============================================================================
const tokenManager = new ServiceTokenManager({
tokenUrl: process.env.TOKEN_URL!, // e.g. https://keycloak/realms/mycompany/...
clientId: process.env.CLIENT_ID!, // order-service
clientSecret: process.env.CLIENT_SECRET!,
});
const paymentClient = new OAuth2HttpClient({
baseURL: 'https://payment-service.internal',
tokenManager,
scope: 'payments:write',
audience: 'https://payment-service.internal',
});
const inventoryClient = new OAuth2HttpClient({
baseURL: 'https://inventory-service.internal',
tokenManager,
scope: 'inventory:read',
audience: 'https://inventory-service.internal',
});
// Usage
async function processOrder(orderId: string) {
const stock = await inventoryClient.get<{ available: number }>(
`/api/inventory/check/${orderId}`
);
if (stock.available > 0) {
const charge = await paymentClient.post('/api/payments/charge', {
orderId,
amount: 9999,
});
return { success: true, chargeId: charge };
}
}import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.HttpClientErrorException;
// ============================================================================
// OAuth2-authenticated HTTP Client
// ============================================================================
public record OAuth2HttpClientConfig(
String baseURL,
ServiceTokenManager tokenManager,
String scope,
String audience,
int timeoutMs
) {}
@Component
public class OAuth2HttpClient {
private final RestClient restClient;
private final ServiceTokenManager tokenManager;
private final String scope;
private final String audience;
public OAuth2HttpClient(OAuth2HttpClientConfig config) {
this.tokenManager = config.tokenManager();
this.scope = config.scope();
this.audience = config.audience();
this.restClient = RestClient.builder()
.baseUrl(config.baseURL())
.build();
}
private String getBearerToken() {
return "Bearer " + tokenManager.getToken(scope, audience);
}
public <T> T post(String path, Object body, Class<T> responseType) {
try {
return restClient.post()
.uri(path)
.header(HttpHeaders.AUTHORIZATION, getBearerToken())
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(responseType);
} catch (HttpClientErrorException.Unauthorized e) {
// Invalidate cached token and retry once
tokenManager.getClient(scope, audience).invalidate();
return restClient.post()
.uri(path)
.header(HttpHeaders.AUTHORIZATION, getBearerToken())
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(responseType);
}
}
public <T> T get(String path, Class<T> responseType) {
try {
return restClient.get()
.uri(path)
.header(HttpHeaders.AUTHORIZATION, getBearerToken())
.retrieve()
.body(responseType);
} catch (HttpClientErrorException.Unauthorized e) {
tokenManager.getClient(scope, audience).invalidate();
return restClient.get()
.uri(path)
.header(HttpHeaders.AUTHORIZATION, getBearerToken())
.retrieve()
.body(responseType);
}
}
}
// ============================================================================
// Wire Everything Together (Order Service) — Spring @Configuration
// ============================================================================
@Configuration
public class ServiceClientsConfig {
@Bean
public ServiceTokenManager serviceTokenManager(
@Value("${oauth2.token-url}") String tokenUrl,
@Value("${oauth2.client-id}") String clientId,
@Value("${oauth2.client-secret}") String clientSecret) {
return new ServiceTokenManager(
new OAuth2Config(tokenUrl, clientId, clientSecret, "", null, 60));
}
@Bean
public OAuth2HttpClient paymentClient(ServiceTokenManager tokenManager) {
return new OAuth2HttpClient(new OAuth2HttpClientConfig(
"https://payment-service.internal", tokenManager,
"payments:write", "https://payment-service.internal", 5000));
}
@Bean
public OAuth2HttpClient inventoryClient(ServiceTokenManager tokenManager) {
return new OAuth2HttpClient(new OAuth2HttpClientConfig(
"https://inventory-service.internal", tokenManager,
"inventory:read", "https://inventory-service.internal", 5000));
}
}
// Usage
@Service
public class OrderProcessor {
private final OAuth2HttpClient paymentClient;
private final OAuth2HttpClient inventoryClient;
public Map<String, Object> processOrder(String orderId) {
var stock = inventoryClient.get(
"/api/inventory/check/" + orderId, Map.class);
if ((Integer) stock.get("available") > 0) {
var charge = paymentClient.post(
"/api/payments/charge",
Map.of("orderId", orderId, "amount", 9999),
Map.class);
return Map.of("success", true, "chargeId", charge);
}
return Map.of("success", false);
}
}import aiohttp
import os
from typing import Optional, TypeVar, Type
from dataclasses import dataclass
T = TypeVar("T")
# ============================================================================
# OAuth2-authenticated HTTP Client
# ============================================================================
@dataclass
class OAuth2HttpClientConfig:
base_url: str
token_manager: "ServiceTokenManager"
scope: str
audience: Optional[str] = None
timeout: int = 5
class OAuth2HttpClient:
def __init__(self, config: OAuth2HttpClientConfig):
self._config = config
async def _get_auth_headers(self) -> dict[str, str]:
token = await self._config.token_manager.get_token(
self._config.scope, self._config.audience
)
return {"Authorization": f"Bearer {token}"}
async def post(self, path: str, data: object) -> dict:
url = self._config.base_url.rstrip("/") + path
headers = await self._get_auth_headers()
timeout = aiohttp.ClientTimeout(total=self._config.timeout)
async with aiohttp.ClientSession() as session:
async with session.post(url, json=data, headers=headers, timeout=timeout) as resp:
if resp.status == 401:
# Invalidate cached token and retry once
self._config.token_manager.get_client(
self._config.scope, self._config.audience
).invalidate()
headers = await self._get_auth_headers()
async with session.post(url, json=data, headers=headers, timeout=timeout) as retry:
retry.raise_for_status()
return await retry.json()
resp.raise_for_status()
return await resp.json()
async def get(self, path: str, params: Optional[dict] = None) -> dict:
url = self._config.base_url.rstrip("/") + path
headers = await self._get_auth_headers()
timeout = aiohttp.ClientTimeout(total=self._config.timeout)
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, headers=headers, timeout=timeout) as resp:
if resp.status == 401:
self._config.token_manager.get_client(
self._config.scope, self._config.audience
).invalidate()
headers = await self._get_auth_headers()
async with session.get(url, params=params, headers=headers, timeout=timeout) as retry:
retry.raise_for_status()
return await retry.json()
resp.raise_for_status()
return await resp.json()
# ============================================================================
# Wire Everything Together (Order Service)
# ============================================================================
token_manager = ServiceTokenManager(OAuth2Config(
token_url=os.environ["TOKEN_URL"],
client_id=os.environ["CLIENT_ID"],
client_secret=os.environ["CLIENT_SECRET"],
scope="",
))
payment_client = OAuth2HttpClient(OAuth2HttpClientConfig(
base_url="https://payment-service.internal",
token_manager=token_manager,
scope="payments:write",
audience="https://payment-service.internal",
))
inventory_client = OAuth2HttpClient(OAuth2HttpClientConfig(
base_url="https://inventory-service.internal",
token_manager=token_manager,
scope="inventory:read",
audience="https://inventory-service.internal",
))
# Usage
async def process_order(order_id: str) -> dict:
stock = await inventory_client.get(f"/api/inventory/check/{order_id}")
if stock.get("available", 0) > 0:
charge = await payment_client.post("/api/payments/charge", {
"orderId": order_id,
"amount": 9999,
})
return {"success": True, "chargeId": charge}
return {"success": False}using System.Net.Http.Json;
using System.Text.Json;
// ============================================================================
// OAuth2-authenticated HTTP Client
// ============================================================================
public record OAuth2HttpClientConfig(
string BaseURL,
ServiceTokenManager TokenManager,
string Scope,
string? Audience = null,
int TimeoutMs = 5000
);
public class OAuth2HttpClient
{
private readonly HttpClient _httpClient;
private readonly ServiceTokenManager _tokenManager;
private readonly string _scope;
private readonly string? _audience;
public OAuth2HttpClient(OAuth2HttpClientConfig config, HttpClient httpClient)
{
_tokenManager = config.TokenManager;
_scope = config.Scope;
_audience = config.Audience;
_httpClient = httpClient;
_httpClient.BaseAddress = new Uri(config.BaseURL);
_httpClient.Timeout = TimeSpan.FromMilliseconds(config.TimeoutMs);
}
private async Task<string> GetBearerTokenAsync() =>
"Bearer " + await _tokenManager.GetTokenAsync(_scope, _audience);
public async Task<T?> PostAsync<T>(string path, object body)
{
var token = await GetBearerTokenAsync();
var request = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = JsonContent.Create(body),
Headers = { { "Authorization", token } }
};
var response = await _httpClient.SendAsync(request);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
// Invalidate cached token and retry once
_tokenManager.GetClient(_scope, _audience).Invalidate();
token = await GetBearerTokenAsync();
request = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = JsonContent.Create(body),
Headers = { { "Authorization", token } }
};
response = await _httpClient.SendAsync(request);
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<T>();
}
public async Task<T?> GetAsync<T>(string path, Dictionary<string, string>? queryParams = null)
{
var uri = path;
if (queryParams?.Count > 0)
uri += "?" + string.Join("&", queryParams.Select(kv =>
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var token = await GetBearerTokenAsync();
var request = new HttpRequestMessage(HttpMethod.Get, uri)
{
Headers = { { "Authorization", token } }
};
var response = await _httpClient.SendAsync(request);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_tokenManager.GetClient(_scope, _audience).Invalidate();
token = await GetBearerTokenAsync();
request = new HttpRequestMessage(HttpMethod.Get, uri)
{
Headers = { { "Authorization", token } }
};
response = await _httpClient.SendAsync(request);
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<T>();
}
}
// ============================================================================
// Wire Everything Together (Order Service) — in Program.cs / DI setup
// ============================================================================
// builder.Services.AddSingleton<ServiceTokenManager>(...);
// builder.Services.AddHttpClient<OAuth2HttpClient>();
// Usage
public class OrderProcessor
{
private readonly OAuth2HttpClient _paymentClient;
private readonly OAuth2HttpClient _inventoryClient;
public async Task<object> ProcessOrderAsync(string orderId)
{
var stock = await _inventoryClient.GetAsync<JsonElement>(
$"/api/inventory/check/{orderId}");
if (stock?.GetProperty("available").GetInt32() > 0)
{
var charge = await _paymentClient.PostAsync<JsonElement>(
"/api/payments/charge",
new { orderId, amount = 9999 });
return new { success = true, chargeId = charge };
}
return new { success = false };
}
}Part 4: Token Validation on the Receiving Service
The receiving service validates the Bearer token — either by verifying the JWT signature locally, or by introspecting it with the authorization server:
import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';
import express from 'express';
// ============================================================================
// JWKS-based JWT Verification (offline — no round-trip to auth server)
// ============================================================================
// Cache the JWKS remotely — jose handles key caching and rotation automatically
const JWKS = createRemoteJWKSet(
new URL(process.env.JWKS_URI!) // e.g. https://keycloak/realms/mycompany/protocol/openid-connect/certs
);
interface ServiceClaims extends JWTPayload {
scope?: string;
azp?: string; // Authorized party (Keycloak: the client_id)
client_id?: string; // Auth0 uses this field
}
async function verifyServiceToken(token: string): Promise<ServiceClaims> {
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.TOKEN_ISSUER!, // https://keycloak/realms/mycompany
audience: process.env.SERVICE_AUDIENCE!, // https://payment-service.internal
algorithms: ['RS256', 'ES256'],
});
return payload as ServiceClaims;
}
// ============================================================================
// Scope Enforcement
// ============================================================================
function requireScope(...requiredScopes: string[]) {
return async (
req: express.Request,
res: express.Response,
next: express.NextFunction
): Promise<void> => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing Bearer token' });
return;
}
const token = authHeader.slice(7);
try {
const claims = await verifyServiceToken(token);
// Parse scopes — both space-separated strings and arrays are common
const tokenScopes = typeof claims.scope === 'string'
? claims.scope.split(' ')
: (claims.scope as string[] | undefined) ?? [];
const missingScopes = requiredScopes.filter(s => !tokenScopes.includes(s));
if (missingScopes.length > 0) {
res.status(403).json({
error: 'Insufficient scope',
required: requiredScopes,
missing: missingScopes,
});
return;
}
// Attach claims for route handlers
const clientId = claims.azp || claims.client_id || claims.sub;
(req as any).serviceIdentity = clientId;
(req as any).tokenClaims = claims;
next();
} catch (error: any) {
if (error.code === 'ERR_JWT_EXPIRED') {
res.status(401).json({ error: 'Token expired' });
} else if (error.code === 'ERR_JWS_INVALID') {
res.status(401).json({ error: 'Invalid token signature' });
} else {
res.status(401).json({ error: 'Token verification failed' });
}
}
};
}
// ============================================================================
// Payment Service Routes
// ============================================================================
app.post(
'/api/payments/charge',
requireScope('payments:write'),
async (req: express.Request, res: express.Response) => {
const callerClientId = (req as any).serviceIdentity;
console.info('Payment charge request', {
from: callerClientId,
amount: req.body.amount,
});
res.json({
success: true,
chargeId: `ch_${Date.now()}`,
processedFor: callerClientId,
});
}
);
app.get(
'/api/payments/history',
requireScope('payments:read'),
async (req: express.Request, res: express.Response) => {
res.json({ payments: [] });
}
);import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.*;
import com.nimbusds.jwt.*;
import com.nimbusds.jwt.proc.*;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.stereotype.Component;
import java.net.URL;
import java.util.*;
// ============================================================================
// JWKS-based JWT Verification (Spring Security OAuth2 Resource Server)
// ============================================================================
// In application.yml:
// spring.security.oauth2.resourceserver.jwt.jwk-set-uri: ${JWKS_URI}
// spring.security.oauth2.resourceserver.jwt.issuer-uri: ${TOKEN_ISSUER}
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter()))
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/payments/charge").hasAuthority("SCOPE_payments:write")
.requestMatchers("/api/payments/history").hasAuthority("SCOPE_payments:read")
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtConverter() {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
// Parse scopes from JWT claim
String scopeClaim = jwt.getClaimAsString("scope");
if (scopeClaim == null) return List.of();
return Arrays.stream(scopeClaim.split(" "))
.map(s -> (GrantedAuthority) () -> "SCOPE_" + s)
.toList();
});
return converter;
}
}
// ============================================================================
// Payment Service Controller
// ============================================================================
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
@PostMapping("/charge")
@PreAuthorize("hasAuthority('SCOPE_payments:write')")
public ResponseEntity<Map<String, Object>> charge(
@RequestBody Map<String, Object> request,
@AuthenticationPrincipal Jwt jwt) {
String callerClientId = Optional.ofNullable(jwt.getClaimAsString("azp"))
.orElse(jwt.getClaimAsString("client_id"));
log.info("Payment charge request from={}, amount={}", callerClientId,
request.get("amount"));
return ResponseEntity.ok(Map.of(
"success", true,
"chargeId", "ch_" + System.currentTimeMillis(),
"processedFor", callerClientId
));
}
@GetMapping("/history")
@PreAuthorize("hasAuthority('SCOPE_payments:read')")
public ResponseEntity<Map<String, Object>> history() {
return ResponseEntity.ok(Map.of("payments", List.of()));
}
}import os
from typing import Optional
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError, ExpiredSignatureError
import httpx
# ============================================================================
# JWKS-based JWT Verification (offline — no round-trip to auth server)
# ============================================================================
# Fetch JWKS once at startup and cache
_jwks_cache: dict | None = None
async def get_jwks() -> dict:
global _jwks_cache
if _jwks_cache is None:
async with httpx.AsyncClient() as client:
resp = await client.get(os.environ["JWKS_URI"])
resp.raise_for_status()
_jwks_cache = resp.json()
return _jwks_cache
security = HTTPBearer()
async def verify_service_token(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
token = credentials.credentials
jwks = await get_jwks()
try:
payload = jwt.decode(
token,
jwks,
algorithms=["RS256", "ES256"],
issuer=os.environ["TOKEN_ISSUER"],
audience=os.environ["SERVICE_AUDIENCE"],
options={"verify_aud": True},
)
return payload
except ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except JWTError:
raise HTTPException(status_code=401, detail="Token verification failed")
# ============================================================================
# Scope Enforcement
# ============================================================================
def require_scope(*required_scopes: str):
async def dependency(claims: dict = Depends(verify_service_token)) -> dict:
scope_claim = claims.get("scope", "")
token_scopes = scope_claim.split(" ") if isinstance(scope_claim, str) else scope_claim
missing = [s for s in required_scopes if s not in token_scopes]
if missing:
raise HTTPException(
status_code=403,
detail={
"error": "Insufficient scope",
"required": list(required_scopes),
"missing": missing,
},
)
return claims
return dependency
# ============================================================================
# Payment Service Routes
# ============================================================================
app = FastAPI()
@app.post("/api/payments/charge")
async def charge(
request: Request,
claims: dict = Depends(require_scope("payments:write")),
):
caller_client_id = claims.get("azp") or claims.get("client_id") or claims.get("sub")
body = await request.json()
return {
"success": True,
"chargeId": f"ch_{int(time.time() * 1000)}",
"processedFor": caller_client_id,
}
@app.get("/api/payments/history")
async def history(claims: dict = Depends(require_scope("payments:read"))):
return {"payments": []}using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
// ============================================================================
// JWKS-based JWT Verification (ASP.NET Core + Microsoft.Identity.Web)
// ============================================================================
// In Program.cs:
// builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
// .AddJwtBearer(options =>
// {
// options.Authority = Environment.GetEnvironmentVariable("TOKEN_ISSUER");
// options.Audience = Environment.GetEnvironmentVariable("SERVICE_AUDIENCE");
// options.TokenValidationParameters = new TokenValidationParameters
// {
// ValidateIssuer = true,
// ValidateAudience = true,
// ValidateLifetime = true,
// ValidAlgorithms = new[] { "RS256", "ES256" },
// };
// });
// ============================================================================
// Scope Enforcement — custom policy requirement
// ============================================================================
public class ScopeRequirement : IAuthorizationRequirement
{
public string[] RequiredScopes { get; }
public ScopeRequirement(params string[] scopes) => RequiredScopes = scopes;
}
public class ScopeHandler : AuthorizationHandler<ScopeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, ScopeRequirement requirement)
{
var scopeClaim = context.User.FindFirst("scope")?.Value ?? "";
var tokenScopes = scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var missing = requirement.RequiredScopes
.Where(s => !tokenScopes.Contains(s))
.ToList();
if (missing.Count == 0)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
// ============================================================================
// Payment Service Controller
// ============================================================================
[ApiController]
[Route("api/payments")]
[Authorize]
public class PaymentController : ControllerBase
{
[HttpPost("charge")]
[Authorize(Policy = "payments:write")]
public IActionResult Charge([FromBody] JsonElement body)
{
var callerClientId = User.FindFirst("azp")?.Value
?? User.FindFirst("client_id")?.Value
?? User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
return Ok(new
{
success = true,
chargeId = $"ch_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
processedFor = callerClientId
});
}
[HttpGet("history")]
[Authorize(Policy = "payments:read")]
public IActionResult History() => Ok(new { payments = Array.Empty<object>() });
}
// Register policies in Program.cs:
// builder.Services.AddAuthorization(options =>
// {
// options.AddPolicy("payments:write", p =>
// p.Requirements.Add(new ScopeRequirement("payments:write")));
// options.AddPolicy("payments:read", p =>
// p.Requirements.Add(new ScopeRequirement("payments:read")));
// });
// builder.Services.AddSingleton<IAuthorizationHandler, ScopeHandler>();Part 5: Token Introspection (for Opaque Tokens)
If your authorization server issues opaque tokens (not JWTs), verification requires a round-trip:
// ============================================================================
// Token Introspection (RFC 7662)
// ============================================================================
interface IntrospectionResponse {
active: boolean;
scope?: string;
client_id?: string;
sub?: string;
exp?: number;
iat?: number;
aud?: string | string[];
}
interface CachedIntrospection {
response: IntrospectionResponse;
cachedAt: number;
ttl: number; // seconds to cache
}
const introspectionCache = new Map<string, CachedIntrospection>();
async function introspectToken(token: string): Promise<IntrospectionResponse> {
// Cache introspection results (cache inactive tokens for 30s, active for 60s)
const tokenHash = require('crypto')
.createHash('sha256')
.update(token)
.digest('hex')
.slice(0, 16);
const cached = introspectionCache.get(tokenHash);
if (cached && Date.now() - cached.cachedAt < cached.ttl * 1000) {
return cached.response;
}
const response = await axios.post<IntrospectionResponse>(
process.env.INTROSPECTION_ENDPOINT!,
new URLSearchParams({ token }),
{
auth: {
username: process.env.INTROSPECTION_CLIENT_ID!,
password: process.env.INTROSPECTION_CLIENT_SECRET!,
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}
);
const result = response.data;
introspectionCache.set(tokenHash, {
response: result,
cachedAt: Date.now(),
ttl: result.active ? 60 : 30, // Cache active tokens longer than inactive
});
return result;
}import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.util.LinkedMultiValueMap;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
// ============================================================================
// Token Introspection (RFC 7662)
// ============================================================================
public record IntrospectionResponse(
boolean active,
String scope,
String clientId,
String sub,
Long exp,
Long iat,
Object aud
) {}
record CachedIntrospection(IntrospectionResponse response, long cachedAtMs, int ttlSeconds) {}
@Service
public class TokenIntrospectionService {
private final RestClient restClient;
private final String introspectionEndpoint;
private final String clientId;
private final String clientSecret;
private final ConcurrentHashMap<String, CachedIntrospection> cache = new ConcurrentHashMap<>();
public TokenIntrospectionService(
@Value("${oauth2.introspection-endpoint}") String endpoint,
@Value("${oauth2.introspection-client-id}") String clientId,
@Value("${oauth2.introspection-client-secret}") String clientSecret) {
this.introspectionEndpoint = endpoint;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.restClient = RestClient.create();
}
public IntrospectionResponse introspectToken(String token) throws Exception {
// Hash token for cache key
MessageDigest sha = MessageDigest.getInstance("SHA-256");
String tokenHash = HexFormat.of()
.formatHex(sha.digest(token.getBytes(StandardCharsets.UTF_8)))
.substring(0, 16);
CachedIntrospection cached = cache.get(tokenHash);
if (cached != null &&
System.currentTimeMillis() - cached.cachedAtMs() < cached.ttlSeconds() * 1000L) {
return cached.response();
}
var params = new LinkedMultiValueMap<String, String>();
params.add("token", token);
var response = restClient.post()
.uri(introspectionEndpoint)
.headers(h -> h.setBasicAuth(clientId, clientSecret))
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(params)
.retrieve()
.body(IntrospectionResponse.class);
int ttl = response.active() ? 60 : 30; // Cache active tokens longer
cache.put(tokenHash, new CachedIntrospection(
response, System.currentTimeMillis(), ttl));
return response;
}
}import hashlib
import time
import aiohttp
import os
from dataclasses import dataclass
from typing import Optional
# ============================================================================
# Token Introspection (RFC 7662)
# ============================================================================
@dataclass
class IntrospectionResponse:
active: bool
scope: Optional[str] = None
client_id: Optional[str] = None
sub: Optional[str] = None
exp: Optional[int] = None
iat: Optional[int] = None
aud: Optional[str | list[str]] = None
@dataclass
class CachedIntrospection:
response: IntrospectionResponse
cached_at: float
ttl: int # seconds to cache
_introspection_cache: dict[str, CachedIntrospection] = {}
async def introspect_token(token: str) -> IntrospectionResponse:
# Hash token for cache key
token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
cached = _introspection_cache.get(token_hash)
if cached and time.time() - cached.cached_at < cached.ttl:
return cached.response
async with aiohttp.ClientSession() as session:
async with session.post(
os.environ["INTROSPECTION_ENDPOINT"],
data={"token": token},
auth=aiohttp.BasicAuth(
os.environ["INTROSPECTION_CLIENT_ID"],
os.environ["INTROSPECTION_CLIENT_SECRET"],
),
headers={"Content-Type": "application/x-www-form-urlencoded"},
) as resp:
resp.raise_for_status()
data = await resp.json()
result = IntrospectionResponse(**{k: v for k, v in data.items()
if k in IntrospectionResponse.__dataclass_fields__})
_introspection_cache[token_hash] = CachedIntrospection(
response=result,
cached_at=time.time(),
ttl=60 if result.active else 30, # Cache active tokens longer than inactive
)
return resultusing System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
// ============================================================================
// Token Introspection (RFC 7662)
// ============================================================================
public record IntrospectionResponse(
bool Active,
string? Scope,
string? ClientId,
string? Sub,
long? Exp,
long? Iat,
object? Aud
);
record CachedIntrospection(IntrospectionResponse Response, long CachedAtMs, int TtlSeconds);
public class TokenIntrospectionService
{
private readonly HttpClient _httpClient;
private readonly string _endpoint;
private readonly ConcurrentDictionary<string, CachedIntrospection> _cache = new();
public TokenIntrospectionService(HttpClient httpClient,
IConfiguration config)
{
_httpClient = httpClient;
_endpoint = config["OAuth2:IntrospectionEndpoint"]!;
var clientId = config["OAuth2:IntrospectionClientId"]!;
var clientSecret = config["OAuth2:IntrospectionClientSecret"]!;
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}"));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
public async Task<IntrospectionResponse> IntrospectTokenAsync(string token)
{
// Hash token for cache key
var tokenHash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(token)))[..16].ToLower();
if (_cache.TryGetValue(tokenHash, out var cached) &&
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - cached.CachedAtMs
< cached.TtlSeconds * 1000L)
{
return cached.Response;
}
var response = await _httpClient.PostAsync(_endpoint,
new FormUrlEncodedContent(new[] {
new KeyValuePair<string, string>("token", token)
}));
response.EnsureSuccessStatusCode();
var data = await response.Content.ReadFromJsonAsync<IntrospectionResponse>();
var ttl = data!.Active ? 60 : 30; // Cache active tokens longer than inactive
_cache[tokenHash] = new CachedIntrospection(
data,
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
ttl
);
return data;
}
}Part 6: Working with Multiple Identity Providers
Different providers have slightly different implementations of client credentials:
// ============================================================================
// Provider-specific configurations
// ============================================================================
type ProviderConfig = {
tokenUrl: string;
extraParams?: Record<string, string>;
};
function getProviderConfig(provider: 'keycloak' | 'auth0' | 'azure-ad' | 'aws-cognito'): ProviderConfig {
switch (provider) {
case 'keycloak':
return {
tokenUrl: `${process.env.KEYCLOAK_URL}/realms/${process.env.REALM}/protocol/openid-connect/token`,
// scope param is supported directly
};
case 'auth0':
return {
tokenUrl: `https://${process.env.AUTH0_DOMAIN}/oauth/token`,
extraParams: {
audience: process.env.AUTH0_AUDIENCE!, // Auth0 requires explicit audience
},
};
case 'azure-ad':
return {
tokenUrl: `https://login.microsoftonline.com/${process.env.TENANT_ID}/oauth2/v2.0/token`,
extraParams: {
// Azure AD uses /.default scope to grant all pre-configured permissions
scope: `${process.env.AZURE_APP_ID_URI}/.default`,
},
};
case 'aws-cognito':
return {
tokenUrl: `https://${process.env.COGNITO_DOMAIN}/oauth2/token`,
// Cognito uses custom scopes defined in the resource server
};
}
}
// ============================================================================
// Unified token fetcher (handles provider differences)
// ============================================================================
async function fetchClientCredentialsToken(
provider: 'keycloak' | 'auth0' | 'azure-ad' | 'aws-cognito',
clientId: string,
clientSecret: string,
scope: string
): Promise<{ accessToken: string; expiresIn: number }> {
const { tokenUrl, extraParams = {} } = getProviderConfig(provider);
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope,
...extraParams,
});
const response = await axios.post<{ access_token: string; expires_in: number }>(
tokenUrl,
params.toString(),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
return {
accessToken: response.data.access_token,
expiresIn: response.data.expires_in,
};
}import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.RestClient;
import java.util.Map;
// ============================================================================
// Provider-specific configurations
// ============================================================================
public record ProviderConfig(String tokenUrl, Map<String, String> extraParams) {}
public record TokenResult(String accessToken, int expiresIn) {}
@Service
public class MultiProviderTokenService {
private final RestClient restClient = RestClient.create();
public ProviderConfig getProviderConfig(String provider) {
return switch (provider) {
case "keycloak" -> new ProviderConfig(
System.getenv("KEYCLOAK_URL") + "/realms/"
+ System.getenv("REALM") + "/protocol/openid-connect/token",
Map.of()
);
case "auth0" -> new ProviderConfig(
"https://" + System.getenv("AUTH0_DOMAIN") + "/oauth/token",
Map.of("audience", System.getenv("AUTH0_AUDIENCE"))
);
case "azure-ad" -> new ProviderConfig(
"https://login.microsoftonline.com/"
+ System.getenv("TENANT_ID") + "/oauth2/v2.0/token",
Map.of("scope", System.getenv("AZURE_APP_ID_URI") + "/.default")
);
case "aws-cognito" -> new ProviderConfig(
"https://" + System.getenv("COGNITO_DOMAIN") + "/oauth2/token",
Map.of()
);
default -> throw new IllegalArgumentException("Unknown provider: " + provider);
};
}
// ============================================================================
// Unified token fetcher (handles provider differences)
// ============================================================================
public TokenResult fetchClientCredentialsToken(
String provider, String clientId, String clientSecret, String scope) {
var config = getProviderConfig(provider);
var params = new LinkedMultiValueMap<String, String>();
params.add("grant_type", "client_credentials");
params.add("client_id", clientId);
params.add("client_secret", clientSecret);
params.add("scope", scope);
config.extraParams().forEach(params::add);
var response = restClient.post()
.uri(config.tokenUrl())
.contentType(org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED)
.body(params)
.retrieve()
.body(Map.class);
return new TokenResult(
(String) response.get("access_token"),
(Integer) response.get("expires_in")
);
}
}import os
import aiohttp
from dataclasses import dataclass, field
from typing import Literal
# ============================================================================
# Provider-specific configurations
# ============================================================================
Provider = Literal["keycloak", "auth0", "azure-ad", "aws-cognito"]
@dataclass
class ProviderConfig:
token_url: str
extra_params: dict[str, str] = field(default_factory=dict)
@dataclass
class TokenResult:
access_token: str
expires_in: int
def get_provider_config(provider: Provider) -> ProviderConfig:
match provider:
case "keycloak":
return ProviderConfig(
token_url=(
f"{os.environ['KEYCLOAK_URL']}/realms/"
f"{os.environ['REALM']}/protocol/openid-connect/token"
)
)
case "auth0":
return ProviderConfig(
token_url=f"https://{os.environ['AUTH0_DOMAIN']}/oauth/token",
extra_params={"audience": os.environ["AUTH0_AUDIENCE"]},
)
case "azure-ad":
return ProviderConfig(
token_url=(
f"https://login.microsoftonline.com/"
f"{os.environ['TENANT_ID']}/oauth2/v2.0/token"
),
extra_params={
# Azure AD uses /.default scope for pre-configured permissions
"scope": f"{os.environ['AZURE_APP_ID_URI']}/.default"
},
)
case "aws-cognito":
return ProviderConfig(
token_url=f"https://{os.environ['COGNITO_DOMAIN']}/oauth2/token"
)
# ============================================================================
# Unified token fetcher (handles provider differences)
# ============================================================================
async def fetch_client_credentials_token(
provider: Provider,
client_id: str,
client_secret: str,
scope: str,
) -> TokenResult:
config = get_provider_config(provider)
params = {
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": scope,
**config.extra_params,
}
async with aiohttp.ClientSession() as session:
async with session.post(
config.token_url,
data=params,
headers={"Content-Type": "application/x-www-form-urlencoded"},
) as resp:
resp.raise_for_status()
data = await resp.json()
return TokenResult(
access_token=data["access_token"],
expires_in=data["expires_in"],
)using System.Net.Http.Json;
// ============================================================================
// Provider-specific configurations
// ============================================================================
public record ProviderConfig(string TokenUrl, Dictionary<string, string> ExtraParams);
public record TokenResult(string AccessToken, int ExpiresIn);
public class MultiProviderTokenService
{
private readonly HttpClient _httpClient;
public MultiProviderTokenService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public ProviderConfig GetProviderConfig(string provider) => provider switch
{
"keycloak" => new ProviderConfig(
$"{Environment.GetEnvironmentVariable("KEYCLOAK_URL")}/realms/"
+ $"{Environment.GetEnvironmentVariable("REALM")}/protocol/openid-connect/token",
new Dictionary<string, string>()
),
"auth0" => new ProviderConfig(
$"https://{Environment.GetEnvironmentVariable("AUTH0_DOMAIN")}/oauth/token",
new Dictionary<string, string>
{
["audience"] = Environment.GetEnvironmentVariable("AUTH0_AUDIENCE")!
}
),
"azure-ad" => new ProviderConfig(
$"https://login.microsoftonline.com/"
+ $"{Environment.GetEnvironmentVariable("TENANT_ID")}/oauth2/v2.0/token",
new Dictionary<string, string>
{
// Azure AD uses /.default scope for all pre-configured permissions
["scope"] = $"{Environment.GetEnvironmentVariable("AZURE_APP_ID_URI")}/.default"
}
),
"aws-cognito" => new ProviderConfig(
$"https://{Environment.GetEnvironmentVariable("COGNITO_DOMAIN")}/oauth2/token",
new Dictionary<string, string>()
),
_ => throw new ArgumentException($"Unknown provider: {provider}")
};
// ============================================================================
// Unified token fetcher (handles provider differences)
// ============================================================================
public async Task<TokenResult> FetchClientCredentialsTokenAsync(
string provider, string clientId, string clientSecret, string scope)
{
var config = GetProviderConfig(provider);
var formData = new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = clientId,
["client_secret"] = clientSecret,
["scope"] = scope,
};
foreach (var (key, value) in config.ExtraParams)
formData[key] = value;
var response = await _httpClient.PostAsync(
config.TokenUrl, new FormUrlEncodedContent(formData));
response.EnsureSuccessStatusCode();
var data = await response.Content.ReadFromJsonAsync<JsonElement>();
return new TokenResult(
data.GetProperty("access_token").GetString()!,
data.GetProperty("expires_in").GetInt32()
);
}
}Part 7: Production Checklist
Authorization Server Setup
- Each service has its own
client_id— never share credentials between services -
client_secretstored in a secrets manager (Vault, AWS Secrets Manager), not env vars in code - Scopes defined with minimum permissions per service pair
- Audience claim set to the specific downstream service URL
- Token TTL set to 1 hour or less
- Authorization server has high availability (multiple instances, health checks)
Token Management
- Tokens are cached until within 60 seconds of expiry
- Single-flight pattern prevents concurrent token fetch storms
- On 401 response: invalidate cache and retry exactly once
- Token fetch failures have retry with exponential backoff
- Never log access tokens (even partially)
Token Validation
- Validate
iss(issuer) matches the expected authorization server - Validate
aud(audience) matches the service’s own identifier - Validate
exp— tokens are not accepted after expiry - Validate scopes before allowing access to protected resources
- Use JWKS endpoint for key discovery — handles key rotation automatically
- JWKS responses are cached (jose library handles this automatically)
Operational
- Client secret rotation procedure documented and tested
- Monitor token fetch error rates per service
- Alert on authorization server latency spikes (affects all service calls)
- Alert on token validation failure spikes (potential compromise or misconfiguration)
- Audit log: which service called which endpoint with which scopes
- Plan for authorization server downtime: services should use cached tokens
Conclusion
OAuth 2.0 Client Credentials is the most production-mature pattern for service-to-service authentication when you need scope-based access control. The ecosystem is enormous — Keycloak, Auth0, Okta, Azure AD, AWS Cognito all implement it. Any API gateway, service mesh, or monitoring tool knows how to work with Bearer tokens.
The tricky parts are operational: keeping the authorization server highly available (it’s in the critical path of every service call), implementing token caching correctly to avoid performance cliffs, and managing the complexity of scopes and audiences as the service graph grows.
Start with short-lived tokens (1 hour), cache aggressively, validate audience and scopes on every request, and treat the authorization server as your most critical piece of infrastructure. Get all of that right, and OAuth 2.0 client credentials will scale with you from 5 services to 500.