Disclaimer: This blog post is automatically generated from project documentation and technical proposals using AI assistance. The content represents our development journey and architectural decisions. Code examples are simplified illustrations and may not reflect the exact production implementation.
Table of Contents
- The Security Challenge
- OAuth2 Token Exchange Architecture
- Token Types and Lifetimes
- Delegation Chain with Act Claims
- WebSocket and SSE Integration
- OpenID Discovery and JWKS
- Key Management and Rotation
- Key Learnings
The Security Challenge
When Caroline and I were designing security for Scores, we faced a tricky problem: how do you securely authenticate WebSocket and Server-Sent Events connections without exposing tokens in URLs or requiring users to re-authenticate constantly?
Traditional REST API security is straightforward—send a bearer token in the Authorization header. But WebSockets and EventSource connections present unique challenges:
- Long-lived connections - Tokens might expire mid-connection
- URL-based tokens are insecure - Tokens in query strings get logged everywhere
- No standard header support - EventSource can’t send custom headers in browsers
- CSRF vulnerabilities - WebSocket connections don’t follow same-origin policy
We needed a solution that worked for:
- Browser clients with session cookies
- Programmatic clients like simulators and test scripts
- Multiple microservices (API, WebSocket, Events)
- Token delegation (services acting on behalf of users)
Caroline suggested OAuth2 Token Exchange (RFC 8693), and Claude helped us implement it.
OAuth2 Token Exchange Architecture
We created a dedicated Exchange Service that implements RFC 8693 token exchange. This service sits between authentication providers and our application services.
graph TB
subgraph "Clients"
Browser["Browser with<br/>Session Cookie"]
CLI["CLI/Simulator with<br/>OIDC Access Token"]
end
subgraph "Exchange Service"
Token["oauth/token<br/>RFC 8693 Endpoint"]
Discovery[".well-known/<br/>openid-configuration"]
JWKS[".well-known/<br/>jwks.json"]
end
subgraph "Application Services"
API["API Service"]
WS["WebSocket Service"]
SSE["EventSource Service"]
end
Browser -->|"1. Session Cookie"| Token
CLI -->|"1. OIDC Access Token"| Token
Token -->|"2. Application JWT"| Browser
Token -->|"2. Application JWT"| CLI
Browser -->|"3. JWT in Header"| API
Browser -->|"3. JWT in Header"| WS
Browser -->|"3. JWT in URL"| SSE
CLI -->|"3. JWT in Header"| WS
API -->|"4. Verify JWT"| JWKS
WS -->|"4. Verify JWT"| JWKS
SSE -->|"4. Verify JWT"| JWKS
style Token fill:#c8e6c9
style Discovery fill:#e3f2fd
style JWKS fill:#e3f2fd
The exchange service accepts two types of input tokens:
Session Tokens: For browser clients with HTTP-only cookies
// No subject_token in body - session read from secure cookie
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
Cookie: session=<encrypted_session>
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token_type=urn:app:params:oauth:token-type:session
&audience=wss://api.example.com
&scope=match:events:read match:stream:write
OIDC Access Tokens: For programmatic clients (simulators, tests)
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<oidc_access_token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=wss://api.example.com
&scope=match:events:read match:stream:write
Both return the same response format:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 14400,
"scope": "match:events:read match:stream:write"
}
Token Types and Lifetimes
One clever aspect Caroline suggested was having two token lifetimes depending on use case:
Standard Access Tokens (4 hours):
- For general API access
- Used across multiple requests
- Cached by clients
- Default when
requested_token_typeis omitted
Connect Tokens (60 seconds):
- Specifically for establishing WebSocket/SSE connections
- Short-lived to minimize replay attack window
- Client exchanges standard token for connect token right before connecting
- Requested via
requested_token_type=urn:app:params:oauth:token-type:connect_token
The JWT structure for a standard token looks like this (with fake values):
{
"iss": "https://exchange.example.com",
"sub": "sha256(user@example.com)",
"aud": "https://api.example.com",
"exp": 1733194800,
"iat": 1733180400,
"jti": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"kid": "2024-12-key-abc123",
"scope": "match:stream:read match:stream:write match:events:read",
"act": {
"sub": "https://api.example.com"
}
}
Privacy Protection: Notice the sub claim is a SHA-256 hash of the email, not the raw email. This prevents email addresses from leaking in logs or token inspection.
Scope Management: We implement strict scope validation:
- Clients can request subset of scopes (privilege restriction)
- Clients cannot escalate privileges (request more scopes than granted)
- Default behavior: grant all available scopes from subject token
Supported scopes include:
match:progress:events:read- Read match progress eventsmatch:progress:events:write- Write match progress eventsmatch:events:read- Read all match eventsmatch:read- Read match datamatch:write- Write match data
Delegation Chain with Act Claims
One of the most interesting aspects was implementing delegation chains using the act (actor) claim. This tracks who is acting on whose behalf:
sequenceDiagram
participant User
participant Browser
participant Exchange
participant API
participant WS as WebSocket
User->>Browser: Authenticate
Browser->>Exchange: Session Cookie
Exchange->>Browser: Access Token (sub: user, act: api)
Browser->>API: Access Token
API->>API: Verify Token User via API
Browser->>Exchange: Exchange for Connect Token
Exchange->>Browser: Connect Token 60s (sub: user, act: websocket)
Browser->>WS: Connect with Token
WS->>WS: Verify Token User via WebSocket
The act claim structure:
{
"sub": "sha256(user@example.com)",
"act": {
"sub": "https://api.example.com",
"iss": "https://exchange.example.com"
}
}
This tells us:
- Primary subject (
sub): The end user - Actor (
act.sub): The service acting on their behalf - Issuer (
act.iss): Who issued the original token
This becomes crucial for audit logs and debugging. When we see a write operation, we can trace it back through the delegation chain to understand the full context.
WebSocket and SSE Integration
The tricky part was integrating token authentication with WebSocket and Server-Sent Events connections.
WebSocket Authentication:
// Client side - exchange for short-lived connect token
const connectResponse = await fetch(
'https://exchange.example.com/oauth/token',
{
method: 'POST',
credentials: 'include', // Send session cookie
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token_type: 'urn:app:params:oauth:token-type:session',
requested_token_type: 'urn:app:params:oauth:token-type:connect_token', // 60s token
audience: 'wss://stream.example.com',
scope: 'match:stream:write',
}),
}
);
const { access_token } = await connectResponse.json();
// Connect with token in URL (WebSocket limitation)
const ws = new WebSocket(
`wss://stream.example.com/matches/123?token=${access_token}`
);
Server-side WebSocket validation:
const wss = new WebSocketServer({
noServer: true,
verifyClient: async (info) => {
const url = new URL(info.req.url, 'wss://stream.example.com');
const token = url.searchParams.get('token');
if (!token) return false;
try {
// Verify JWT with public key from JWKS
const decoded = jwt.verify(token, publicKey, {
issuer: 'https://exchange.example.com',
audience: 'wss://stream.example.com',
algorithms: ['RS256'],
});
// Attach user to connection
info.req.user = decoded.sub;
return true;
} catch (error) {
logger.warn({ error }, 'Token verification failed');
return false;
}
},
});
EventSource Challenge: Browser EventSource API can’t send custom headers, so we use a similar URL-based token approach but with origin validation as additional protection.
When services need to call each other, they use the same token validation mechanism. For example, when the API service needs to publish events, it includes the user’s token in the request to the events service.
Key Learnings
Working through this OAuth2 implementation with Caroline taught us several important lessons:
Start Simple, Add Complexity: We initially tried to implement full OAuth2.0 with PKCE flow, but realized we needed something simpler for our internal services. Token exchange was the right balance.
Validate Early, Validate Often: Every service boundary is a potential security hole. We validate tokens at every entry point—REST APIs, WebSocket connections, and event handlers.
OpenID Discovery and JWKS
To make our tokens verifiable by external services and support standard OAuth2 clients, we implemented OpenID Connect Discovery.
Discovery Endpoint (/.well-known/openid-configuration):
{
"issuer": "https://exchange.example.com",
"token_endpoint": "https://exchange.example.com/oauth/token",
"jwks_uri": "https://exchange.example.com/.well-known/jwks.json",
"grant_types_supported": ["urn:ietf:params:oauth:grant-type:token-exchange"],
"token_endpoint_auth_methods_supported": ["none"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"response_types_supported": ["token"],
"scopes_supported": [
"openid",
"match:stream:read",
"match:stream:write",
"match:events:read"
]
}
JWKS Endpoint (/.well-known/jwks.json):
This exposes our public keys so any service can verify our JWTs without sharing secrets:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "2024-12-key-abc123",
"alg": "RS256",
"n": "xGOr_hP... (base64url modulus)",
"e": "AQAB"
},
{
"kty": "RSA",
"use": "sig",
"kid": "2025-01-key-def456",
"alg": "RS256",
"n": "yHPs_iQ... (base64url modulus)",
"e": "AQAB"
}
]
}
We expose both current and next keys to support graceful key rotation.
Key Management and Rotation
JWT signing uses RS256 (RSA with SHA-256). We generate 2048-bit RSA keys:
# Generate key pair
openssl genrsa -out private_key.pem 2048
openssl rsa -in private_key.pem -pubout -out public_key.pem
# Store as Docker secrets
mkdir -p deployments/docker/secrets/jwt
mv private_key.pem deployments/docker/secrets/jwt/
mv public_key.pem deployments/docker/secrets/jwt/
Key Rotation Strategy:
- Generate new key pair →
jwt_private_key_next - Add to JWKS endpoint (now has 2 keys)
- Start signing new tokens with new key (identified by
kid) - Wait 1 day grace period (old tokens expire)
- Remove old key from JWKS
- Promote next → current
The kid (key ID) claim in JWT header lets verifiers select the correct public key from JWKS. During rotation, both keys work simultaneously.
Key Learnings
RFC 8693 is Powerful: Token exchange is perfect for microservices. It lets you convert authentication tokens into service-specific tokens with appropriate scopes.
Session Cookies + JWT: Using secure session cookies for browsers and OIDC tokens for programmatic clients gives us best of both worlds—security and flexibility.
Short-Lived Tokens for Connections: The 60-second connect tokens dramatically reduce replay attack windows. Clients can get them right before connecting.
Delegation Tracking: The act claim is invaluable for audit logs. We can see the full chain of who’s acting on whose behalf.
JWKS Makes Integration Easy: By exposing public keys via JWKS, any service (even outside our infrastructure) can verify our tokens without coordination.
Privacy Matters: Hashing email addresses in the sub claim prevents PII leakage in logs while maintaining unique user identification.
Scope Validation is Critical: Preventing privilege escalation through strict scope checking ensures clients can’t request more permissions than they were granted.
Working with Caroline and Claude on this taught us that OAuth2 doesn’t have to be intimidating. By following standards (RFC 8693, OpenID Discovery) and using well-tested libraries, we built production-grade security that’s both robust and maintainable. The key is understanding the patterns and applying them systematically across your services.