Designing an Authentication System: OAuth and SSO
Picture this: You're building the next big application, and you've just hit that inevitable moment where you need to figure out authentication. Your product manager wants social login, your security team demands multi-factor authentication, and your users expect seamless access across multiple services. Sound familiar?
Authentication systems are the backbone of modern applications, yet they're often treated as an afterthought or outsourced entirely to third-party services. While managed solutions have their place, understanding how to design robust authentication systems from the ground up is crucial for any senior developer. Whether you're architecting a new system or improving an existing one, the decisions you make here will impact security, user experience, and scalability for years to come.
In this article, we'll dive deep into designing authentication systems using OAuth and Single Sign-On (SSO), covering everything from token management to multi-factor authentication. Think of this as the conversation I wish I'd had before building my first authentication system.
Core Concepts: Building Blocks of Modern Authentication
OAuth 2.0: The Foundation
OAuth 2.0 isn't just about "login with Google" buttons. It's a comprehensive authorization framework that solves the fundamental problem of granting limited access to user resources without sharing credentials. Let me break down the key players:
- Resource Owner: The user who owns the data
- Client: Your application requesting access
- Authorization Server: Issues tokens (Google, GitHub, your own server)
- Resource Server: Hosts the protected resources
The magic happens through a series of redirects and token exchanges. Here's what a typical authorization code flow looks like:
# Step 1: Redirect user to authorization server
def initiate_oauth():
params = {
'client_id': OAUTH_CLIENT_ID,
'response_type': 'code',
'redirect_uri': REDIRECT_URI,
'scope': 'read:user',
'state': generate_csrf_token()
}
auth_url = f"{OAUTH_PROVIDER}/authorize?{urlencode(params)}"
return redirect(auth_url)
# Step 2: Handle callback and exchange code for token
def oauth_callback(code, state):
if not verify_csrf_token(state):
raise SecurityError("Invalid state parameter")
token_data = {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': REDIRECT_URI,
'client_id': OAUTH_CLIENT_ID,
'client_secret': OAUTH_CLIENT_SECRET
}
response = requests.post(f"{OAUTH_PROVIDER}/token", data=token_data)
return response.json()
Single Sign-On: Seamless User Experience
SSO takes OAuth concepts and applies them across multiple applications within your ecosystem. The goal is simple: authenticate once, access everything. But the implementation requires careful coordination between services.
SAML and OpenID Connect (OIDC) are the two dominant SSO protocols. OIDC, built on top of OAuth 2.0, has become the preferred choice for modern applications due to its simplicity and JSON-based approach.
// OIDC ID Token structure (JWT payload)
{
"iss": "https://your-auth-server.com",
"sub": "user123",
"aud": "your-client-id",
"exp": 1640995200,
"iat": 1640991600,
"email": "user@example.com",
"email_verified": true,
"name": "John Doe"
}
Token Architecture: The Heart of the System
Your token strategy determines everything from security posture to user experience. Here's how I typically structure tokens:
Access Tokens: Short-lived (15-60 minutes), carry authorization information
Refresh Tokens: Long-lived (days to months), used to obtain new access tokens
ID Tokens: Contain user identity information (OIDC)
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IssuedAt time.Time `json:"issued_at"`
}
func (t *TokenPair) IsExpired() bool {
return time.Now().After(t.IssuedAt.Add(time.Duration(t.ExpiresIn) * time.Second))
}
Practical Implementation: Building Your Authentication System
Setting Up OAuth Server
Let's build a basic OAuth 2.0 server. I'll use Python with Flask, but the concepts apply regardless of your stack:
from flask import Flask, request, jsonify, redirect
import jwt
import secrets
from datetime import datetime, timedelta
app = Flask(__name__)
class OAuthServer:
def __init__(self):
self.authorization_codes = {}
self.access_tokens = {}
self.refresh_tokens = {}
def generate_authorization_code(self, client_id, user_id, scope):
code = secrets.token_urlsafe(32)
self.authorization_codes[code] = {
'client_id': client_id,
'user_id': user_id,
'scope': scope,
'expires': datetime.utcnow() + timedelta(minutes=10)
}
return code
def exchange_code_for_tokens(self, code, client_id):
if code not in self.authorization_codes:
return None
auth_data = self.authorization_codes[code]
if auth_data['expires'] < datetime.utcnow():
del self.authorization_codes[code]
return None
if auth_data['client_id'] != client_id:
return None
# Generate tokens
access_token = self.generate_access_token(
auth_data['user_id'],
auth_data['scope']
)
refresh_token = self.generate_refresh_token(auth_data['user_id'])
# Clean up authorization code
del self.authorization_codes[code]
return {
'access_token': access_token,
'refresh_token': refresh_token,
'token_type': 'Bearer',
'expires_in': 3600
}
Session Handling Strategy
Session management in a distributed system requires careful thought. Here's a Redis-based session store that works well with OAuth:
import redis
import json
from datetime import timedelta
class SessionStore:
def __init__(self, redis_client):
self.redis = redis_client
def create_session(self, user_id, token_data, expires_in=3600):
session_id = secrets.token_urlsafe(32)
session_data = {
'user_id': user_id,
'access_token': token_data['access_token'],
'refresh_token': token_data['refresh_token'],
'created_at': datetime.utcnow().isoformat(),
'last_accessed': datetime.utcnow().isoformat()
}
self.redis.setex(
f"session:{session_id}",
expires_in,
json.dumps(session_data)
)
return session_id
def get_session(self, session_id):
data = self.redis.get(f"session:{session_id}")
if data:
session = json.loads(data)
# Update last accessed time
session['last_accessed'] = datetime.utcnow().isoformat()
self.redis.setex(
f"session:{session_id}",
3600, # Reset TTL
json.dumps(session)
)
return session
return None
Multi-Factor Authentication Implementation
MFA is no longer optional. Here's how to integrate TOTP (Time-based One-Time Passwords) into your authentication flow:
import pyotp
import qrcode
from io import BytesIO
import base64
class MFAManager:
def setup_totp(self, user_id, user_email):
secret = pyotp.random_base32()
# Store secret (encrypted in production!)
self.store_mfa_secret(user_id, secret)
# Generate QR code for authenticator apps
totp_uri = pyotp.totp.TOTP(secret).provisioning_uri(
name=user_email,
issuer_name="Your App Name"
)
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(totp_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffered = BytesIO()
img.save(buffered)
return {
'secret': secret,
'qr_code': base64.b64encode(buffered.getvalue()).decode(),
'manual_entry_key': secret
}
def verify_totp(self, user_id, token):
secret = self.get_mfa_secret(user_id)
if not secret:
return False
totp = pyotp.TOTP(secret)
return totp.verify(token, valid_window=1) # Allow 30s window
Social Login Integration
Social login sounds simple until you realize each provider has quirks. Here's a flexible approach:
class SocialAuthProvider {
constructor(config) {
this.providers = {
google: {
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo',
scope: 'openid email profile'
},
github: {
authUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
scope: 'user:email'
}
};
}
async authenticateUser(provider, code) {
const config = this.providers[provider];
if (!config) throw new Error(`Provider ${provider} not supported`);
// Exchange code for token
const tokenResponse = await fetch(config.tokenUrl, {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: new URLSearchParams({
client_id: process.env[`${provider.toUpperCase()}_CLIENT_ID`],
client_secret: process.env[`${provider.toUpperCase()}_CLIENT_SECRET`],
code: code,
grant_type: 'authorization_code'
})
});
const tokens = await tokenResponse.json();
// Get user info
const userResponse = await fetch(config.userInfoUrl, {
headers: { 'Authorization': `Bearer ${tokens.access_token}` }
});
return await userResponse.json();
}
}
Password Reset Flow
A secure password reset requires more than just emailing a link. Here's a robust implementation:
class PasswordResetManager:
def initiate_reset(self, email):
user = self.get_user_by_email(email)
if not user:
# Don't reveal if email exists, but log for security monitoring
self.log_security_event('password_reset_attempt', {'email': email})
return {'success': True} # Always return success
# Generate secure token
reset_token = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + timedelta(hours=1)
# Store with rate limiting
self.store_reset_token(user.id, reset_token, expires_at)
# Send email (use template)
self.send_reset_email(user.email, reset_token)
return {'success': True}
def complete_reset(self, token, new_password):
reset_data = self.get_reset_token(token)
if not reset_data or reset_data['expires_at'] < datetime.utcnow():
raise InvalidTokenError("Token expired or invalid")
# Validate password strength
if not self.validate_password_strength(new_password):
raise WeakPasswordError("Password doesn't meet requirements")
# Update password and invalidate all sessions
user_id = reset_data['user_id']
self.update_password(user_id, new_password)
self.invalidate_all_user_sessions(user_id)
self.delete_reset_token(token)
return {'success': True}
Common Pitfalls: Learning From Others' Mistakes
Token Security Blunders
The most common mistake I see is storing tokens in localStorage. Don't do this:
// BAD - XSS vulnerable
localStorage.setItem('access_token', token);
// BETTER - HttpOnly cookies
// Set from server-side with proper flags
app.use(session({
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 3600000
}
}));
CSRF and State Parameter Neglect
Always validate the state parameter in OAuth flows. I've seen too many implementations skip this:
# VULNERABLE - No state validation
def oauth_callback():
code = request.args.get('code')
# Exchange code for token...
# SECURE - Proper state validation
def oauth_callback():
code = request.args.get('code')
state = request.args.get('state')
if not self.validate_state(state, session.get('oauth_state')):
raise SecurityError("Invalid state parameter")
# Now safely exchange code for token
Refresh Token Rotation
Always rotate refresh tokens to limit exposure:
func (s *AuthServer) RefreshTokens(refreshToken string) (*TokenPair, error) {
// Validate current refresh token
claims, err := s.validateRefreshToken(refreshToken)
if err != nil {
return nil, err
}
// Generate new token pair
newTokens := s.generateTokenPair(claims.UserID)
// CRITICAL: Invalidate old refresh token
s.revokeRefreshToken(refreshToken)
return newTokens, nil
}
Session Fixation Prevention
Regenerate session IDs after authentication:
@app.route('/login', methods=['POST'])
def login():
if authenticate_user(email, password):
# CRITICAL: Generate new session ID
session.regenerate()
session['user_id'] = user.id
return redirect('/dashboard')
Real-World Applications: Scale and Architecture
Microservices Authentication
In a microservices architecture, authentication becomes distributed. Here's how companies like Netflix and Spotify handle it:
# API Gateway configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: auth-config
data:
nginx.conf: |
location /api/ {
auth_request /auth;
proxy_pass http://backend-service;
}
location = /auth {
internal;
proxy_pass http://auth-service/validate;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
}
Database Schema for Scale
Here's a production-ready schema that handles millions of users:
-- Users table with partitioning for scale
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255),
mfa_secret VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) PARTITION BY HASH (id);
-- OAuth clients
CREATE TABLE oauth_clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id VARCHAR(255) UNIQUE NOT NULL,
client_secret_hash VARCHAR(255) NOT NULL,
redirect_uris TEXT[],
allowed_scopes TEXT[],
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Sessions with TTL
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
session_token VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address INET,
user_agent TEXT
);
-- Index for performance
CREATE INDEX idx_sessions_user_expires ON user_sessions(user_id, expires_at);
CREATE INDEX idx_sessions_token ON user_sessions(session_token) WHERE expires_at > CURRENT_TIMESTAMP;
Architecture Visualization
When designing these systems, I often use tools like InfraSketch to visualize the authentication flow across services. It helps identify potential bottlenecks and security gaps before implementation.
Performance Considerations
At scale, token validation becomes a bottleneck. Here's how to optimize:
class TokenCache:
def __init__(self, redis_client):
self.redis = redis_client
def validate_token(self, token):
# Check cache first
cached = self.redis.get(f"token_valid:{token}")
if cached:
return json.loads(cached)
# Validate with auth server
result = self.validate_with_auth_server(token)
# Cache for 5 minutes (shorter than token expiry)
self.redis.setex(
f"token_valid:{token}",
300,
json.dumps(result)
)
return result
Key Takeaways: Your Authentication Checklist
Here are the essential points to remember when designing your authentication system:
- Security First: Always use HTTPS, validate state parameters, and implement proper CSRF protection
- Token Strategy: Use short-lived access tokens with refresh token rotation
- Session Management: Store sessions securely with proper invalidation mechanisms
- MFA Integration: Design MFA into your flows from the beginning, not as an afterthought
- Social Login: Abstract provider-specific logic and handle edge cases gracefully
- Scale Considerations: Plan for distributed validation and caching from day one
- User Experience: Balance security with usability, especially in SSO scenarios
Remember, authentication systems are never "done." They require continuous monitoring, updating, and refinement as threats evolve and your application grows.
The investment you make in understanding these patterns will pay dividends throughout your career. Whether you're building the next unicorn startup or improving systems at an established company, solid authentication architecture is fundamental to success.
Ready to put these concepts into practice? Start by sketching out your own authentication system architecture. Consider your specific requirements, constraints, and scale needs. What OAuth flows make sense for your use case? How will you handle session management across services? The best way to truly understand these systems is to design and build them yourself.
Top comments (0)