Field note / fundamentals

publishedupdated 2026-05-01#sso#oauth#jwt#authentication#security

SSO (Single Sign-On) & OAuth 2.0

How SSO works with SAML and OAuth/OIDC, the token flow, JWT internals, and how to implement SSO in a microservices architecture.

Overview

SSO lets users authenticate once and access multiple systems without re-entering credentials. Two dominant protocols: SAML 2.0 (enterprise, XML-based) and OAuth 2.0 + OpenID Connect (web/mobile, JSON/JWT-based). Modern systems use OAuth/OIDC.

Key terms:

  • Identity Provider (IdP): the authority that authenticates users (Google, Okta, Auth0)
  • Service Provider (SP): the app that trusts the IdP (Gmail, Slack, your app)
  • Access Token: short-lived credential for API access (15 min)
  • Refresh Token: long-lived credential to get new access tokens (days/weeks)
  • ID Token: JWT with user identity claims (for SSO/login, not API access)

Architecture

SSO LOGIN FLOW (OAuth 2.0 Authorization Code + PKCE)
════════════════════════════════════════════════════════

  User            Your App            Auth Server (IdP)      Resource API
    │                 │                      │                    │
    │── "Login" ─────▶│                      │                    │
    │                 │── redirect ──────────▶│                    │
    │                 │   ?client_id=app1     │                    │
    │                 │   &redirect_uri=...   │                    │
    │                 │   &code_challenge=... │  (PKCE)            │
    │                 │                      │                    │
    │◀── login page ──────────────────────── │                    │
    │── credentials ─────────────────────────▶│                    │
    │                 │                      │                    │
    │◀── redirect to app?code=AUTH_CODE ──── │                    │
    │                 │                      │                    │
    │                 │── POST /token ───────▶│                    │
    │                 │   code=AUTH_CODE      │                    │
    │                 │   code_verifier=...   │  (PKCE verify)     │
    │                 │◀─ access_token ───────│                    │
    │                 │   refresh_token       │                    │
    │                 │   id_token (JWT)      │                    │
    │                 │                      │                    │
    │◀── logged in ───│                      │                    │
    │                 │── GET /api/profile ──────────────────────▶│
    │                 │   Authorization: Bearer <access_token>     │
    │◀── profile ─────────────────────────────────────────────────│

MULTI-APP SSO SESSION:
════════════════════════════════════════════════════════
  User already logged in to App A (session cookie from IdP)
      │
      │── visits App B ──────────▶│ (not logged in)
      │                 App B ──▶ IdP redirect
      │                 IdP sees session cookie → user already authenticated
      │                 IdP issues tokens to App B immediately
      │◀── logged in to App B without entering credentials ──────────

JWT STRUCTURE:
════════════════════════════════════════════════════════
  eyJhbGciOiJSUzI1NiJ9    .eyJzdWIiOiJ1c2VyMSIsInJvb   .SflKxwRJSMeKKF2QT4f
  └─── Header (Base64) ──────── Payload (Base64) ─────── Signature ───────┘

  Header: { "alg": "RS256", "typ": "JWT" }
  Payload: {
    "sub": "user_id_123",         // subject
    "iss": "https://auth.company.com",  // issuer
    "aud": "api.company.com",     // audience
    "exp": 1714608000,            // expiry (Unix timestamp)
    "iat": 1714604400,            // issued at
    "email": "user@company.com",
    "roles": ["admin", "user"]
  }
  Signature: RS256(base64(header) + "." + base64(payload), privateKey)

Technical Implementation

JWT Validation in Node.js

// src/middleware/auth.ts
import jwt from 'jsonwebtoken';
import { createRemoteJWKSet, jwtVerify } from 'jose';

// Fetch IdP's public keys for signature verification
const JWKS = createRemoteJWKSet(
  new URL('https://auth.company.com/.well-known/jwks.json')
);

export async function verifyToken(token: string): Promise<JWTPayload> {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://auth.company.com',
    audience: 'api.company.com',
  });

  if (!payload.sub) throw new Error('Missing subject claim');
  if ((payload.exp ?? 0) < Date.now() / 1000) throw new Error('Token expired');

  return payload;
}

// Express middleware
export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }

  try {
    req.user = await verifyToken(authHeader.slice(7));
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
};

JWT Validation in Spring Boot (Java)

// Spring Security OAuth2 Resource Server
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthConverter())
                )
            );
        return http.build();
    }
}

// application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.company.com
          # Spring auto-fetches JWKS from /.well-known/openid-configuration

Token Refresh Flow

// Client-side token refresh
export class AuthService {
  private refreshTokenKey = 'refresh_token';  // httpOnly cookie in production

  async getValidAccessToken(): Promise<string> {
    const accessToken = this.getStoredAccessToken();

    // Check if about to expire (within 60 seconds)
    if (accessToken && !this.isExpiringSoon(accessToken)) {
      return accessToken;
    }

    // Refresh
    const refreshToken = this.getRefreshToken();
    if (!refreshToken) throw new AuthError('Not authenticated');

    const response = await fetch('/auth/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refresh_token: refreshToken }),
    });

    const { access_token, refresh_token } = await response.json();
    this.storeTokens(access_token, refresh_token);
    return access_token;
  }
}

Interview Preparation

Q: What is the difference between OAuth 2.0 and OpenID Connect?

OAuth 2.0 is an authorization framework — it defines how to grant access to APIs on behalf of a user (access tokens). It says nothing about who the user is. OpenID Connect (OIDC) is an identity layer on top of OAuth 2.0 — it adds an ID token (JWT) that contains user identity claims (email, name, sub). Use OAuth for API access; use OIDC for login/authentication.

Q: Where should you store JWTs on the client?

  • httpOnly cookie: preferred for web apps. Cannot be accessed by JavaScript (XSS-safe). Automatically sent on every request. Vulnerable to CSRF → mitigate with SameSite=Strict or CSRF tokens.
  • localStorage: easy to use but accessible to JavaScript → any XSS can steal the token. Not recommended for access tokens.
  • In-memory (React state): most secure (no persistence), but lost on page refresh → refresh token needed in httpOnly cookie.

Q: Why use short-lived access tokens + long-lived refresh tokens?

Access tokens are sent on every API request — if stolen, they're valid until expiry. Short TTL (15 min) limits the damage window. Refresh tokens are only sent to the auth server to get new access tokens — less exposure. Refresh tokens can be revoked server-side (add to revocation list). If you had long-lived access tokens, a stolen token gives persistent access.

Q: How do you implement logout in SSO?

Two types: local logout (clear local session/cookies) and single logout (SLO — notify IdP to invalidate the global session across all apps). Local logout is easy. SLO is hard — the IdP must notify all registered apps via back-channel or front-channel logout. Many systems skip SLO and rely on short TTL access tokens instead.


Learning Resources