DEV Community

Cover image for Designing an Authentication System: OAuth and SSO
Matt Frank
Matt Frank

Posted on

Designing an Authentication System: OAuth and SSO

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()
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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
        }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
    }
}));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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;
    }
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)