~/blog/authentication-101

Authentication 101: A Complete Guide to Modern Identity Methods

22 min read
Authentication 101

Authentication gets confusing the moment teams use one word for five different jobs.

The cookie on your monolith, the bearer token on your API, the ID token from your identity provider, the SAML assertion a customer sends during enterprise onboarding, and the client certificate inside your mesh are not interchangeable. They live at different layers and solve different trust problems.

That distinction matters more now than it did a decade ago. Once you are running microservices on Kubernetes, exposing APIs to third parties, or integrating with enterprise identity providers, the network stops being a meaningful trust boundary. Identity becomes part of the control plane.

That is the practical meaning of Zero Trust. Every request needs an explicit identity, and that identity has to make sense for the layer you are operating at.

The problem is that the industry throws all of this into the same "auth" bucket. It is not one mechanism. It is a stack of different mechanisms that happen to sit close to each other.

This post breaks the landscape into five categories, shows what each one is for, and draws the lines people usually blur.

Here is the mental model:


1. Basic Auth Methods

Start with the older credential based patterns. They still show up everywhere, and a few of them are still useful, but most are poor defaults for modern internet facing systems.

1.1. Basic HTTP Authentication

The client sends a username:password pair encoded in base64 inside the Authorization: Basic ... header on every single request. There is no session and no token lifecycle; the client simply resends credentials on every request. Revocation means changing or disabling the underlying credentials.

When to use it: Almost never in production. Legacy internal dashboards or simple automation over a private network where HTTPS is guaranteed.

package main
 
import (
	"crypto/subtle"
	"net/http"
)
 
func basicAuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		user, pass, ok := r.BasicAuth()
 
		// constant-time comparison prevents timing side-channel attacks.
		// In production: fetch the hashed password from a DB and use
		// bcrypt.CompareHashAndPassword(), never hardcode credentials.
		// More on: https://mojoauth.com/hashing/bcrypt-in-go#implementing-bcrypt-in-go
		if !ok ||
			subtle.ConstantTimeCompare([]byte(user), []byte("admin")) != 1 ||
			subtle.ConstantTimeCompare([]byte(pass), []byte("supersecret")) != 1 {
			w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
 
		next.ServeHTTP(w, r)
	})
}
package main
 
import (
	"crypto/subtle"
	"net/http"
)
 
func basicAuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		user, pass, ok := r.BasicAuth()
 
		// constant-time comparison prevents timing side-channel attacks.
		// In production: fetch the hashed password from a DB and use
		// bcrypt.CompareHashAndPassword(), never hardcode credentials.
		// More on: https://mojoauth.com/hashing/bcrypt-in-go#implementing-bcrypt-in-go
		if !ok ||
			subtle.ConstantTimeCompare([]byte(user), []byte("admin")) != 1 ||
			subtle.ConstantTimeCompare([]byte(pass), []byte("supersecret")) != 1 {
			w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
 
		next.ServeHTTP(w, r)
	})
}

1.2. Digest Authentication

An improvement over Basic Auth: the server sends a nonce (a random one-time value), and the client sends a digest of credentials combined with that nonce, rather than sending the password itself. RFC 7616 supports stronger algorithms (such as SHA-256 and SHA-512-256), but many legacy implementations still use MD5.

When to use it: Rarely. It is defined in RFC 7616 and you will still find it in embedded systems (IP cameras, routers) and some legacy enterprise applications. You should not build new systems on legacy MD5-based deployments.

1.3. API Keys

A long, random secret string (e.g., sk-a1b2c3d4e5f6...) issued to a client application. The client includes it in every request, typically via a custom header (X-API-Key) or as a query parameter.

GET /v1/data HTTP/1.1
Host: api.example.com
X-API-Key: sk-a1b2c3d4e5f6g7h8i9j0
GET /v1/data HTTP/1.1
Host: api.example.com
X-API-Key: sk-a1b2c3d4e5f6g7h8i9j0

API keys are application identity, not user identity. They are opaque to the client as capabilities, while the server maps the key to an application identity, scopes, and policy.

When to use it: Machine-to-machine access for third-party integrations, public-facing APIs (Stripe, GitHub, OpenAI all use this pattern), or when you need immediate revocation without token expiry complexity.

Platform Engineer Tip: Never put API keys in URLs. They end up in server access logs. Always use headers. Rotate them. Use short lived keys where possible and scope them aggressively: read only, write only, per resource.

1.4. Session-Based Authentication

The traditional web application pattern. After a user logs in:

  1. The server creates a session record in a store; in-memory, Redis, or a database.
  2. A session ID (an opaque random string) is returned to the client as a Set-Cookie header.
  3. The client sends this cookie automatically on every subsequent request.
  4. The server looks up the session ID, retrieves the associated user context, and processes the request.

Sessions are stateful. That is both the benefit and the cost. Revocation is immediate: delete the record and the user is out. No token expiry games, no blocklists. The trade off is operational. Every request needs a storage lookup, and if you keep sessions in memory, horizontal scaling falls apart quickly. Move them into Redis and you now own another dependency, another failure mode, and another thing to secure.

When to use it: Traditional server-rendered web applications (Rails, Django, PHP). Not a natural fit for mobile clients or distributed microservices where you need stateless token validation for less coupling in those contexts.


2. Token-Based Authentication

Token based auth pushes identity information into the token itself, so the receiver can validate it without hitting a database or session store on every request. That trade is why it became the default shape for APIs and microservices.

2.1 Bearer Tokens

A Bearer Token is any opaque or structured token that grants access if "presented" (hence bearer). The holder of the token is trusted, regardless of who they are. The format is defined by the Authorization: Bearer <token> header scheme (RFC 6750).

The "bearer" label is about the transport mechanism, not the token format. An access token issued by OAuth 2.0 is typically transported as a Bearer Token. A JWT is often transported the same way.

2.2 JWT (JSON Web Tokens)

A JWT (pronounced "jot") is a specific token format: a base64url-encoded, cryptographically signed JSON payload. It is defined by RFC 7519. A JWT has three dot-separated parts:

header.payload.signature
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiZW1yZUBleGFtcGxlLmNvbSIsImV4cCI6MTcxMjAwMDAwMH0.SflKxwRJSMeKKF...
header.payload.signature
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiZW1yZUBleGFtcGxlLmNvbSIsImV4cCI6MTcxMjAwMDAwMH0.SflKxwRJSMeKKF...

The server validates the signature cryptographically, with no database lookup required. That is what makes JWTs fast and easy to scale horizontally.

Example: Validating a JWT with golang-jwt

package main
 
import (
	"fmt"
	"net/http"
	"strings"
 
	"github.com/golang-jwt/jwt/v5"
)
 
// jwtKey is a symmetric HMAC secret used to sign and verify tokens (HS256).
// In production, load this from a secrets manager. Never hardcode it.
var jwtKey = []byte("your-256-bit-secret")
 
func jwtMiddleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")
		if !strings.HasPrefix(authHeader, "Bearer ") {
			http.Error(w, "Forbidden", http.StatusForbidden)
			return
		}
 
		tokenString := strings.TrimPrefix(authHeader, "Bearer ")
 
		// Parse the token and validate its signature.
		// Always pin the expected signing method. It prevents algorithm confusion and avoids validating a legitimately signed token under an algorithm your service never intended to trust.
		token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
			}
			return jwtKey, nil
		})
 
		if err != nil || !token.Valid {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
 
		next.ServeHTTP(w, r)
	}
}
package main
 
import (
	"fmt"
	"net/http"
	"strings"
 
	"github.com/golang-jwt/jwt/v5"
)
 
// jwtKey is a symmetric HMAC secret used to sign and verify tokens (HS256).
// In production, load this from a secrets manager. Never hardcode it.
var jwtKey = []byte("your-256-bit-secret")
 
func jwtMiddleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")
		if !strings.HasPrefix(authHeader, "Bearer ") {
			http.Error(w, "Forbidden", http.StatusForbidden)
			return
		}
 
		tokenString := strings.TrimPrefix(authHeader, "Bearer ")
 
		// Parse the token and validate its signature.
		// Always pin the expected signing method. It prevents algorithm confusion and avoids validating a legitimately signed token under an algorithm your service never intended to trust.
		token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
			}
			return jwtKey, nil
		})
 
		if err != nil || !token.Valid {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
 
		next.ServeHTTP(w, r)
	}
}

Platform Engineer Tip: The JWT Revocation Problem: Because JWTs are stateless, you cannot revoke one before it expires without reintroducing state, for example a Redis token blocklist keyed by the jti claim and trimmed by the token's exp. Keep access token TTLs short: 5 to 15 minutes. Use refresh tokens for longer sessions.

2.3 Access Tokens & Refresh Tokens

These two work as a pair:

TokenLifetimePurposeStorage
Access TokenShort (5 to 15 min)Authorise API callsMemory (SPA) / Secure storage
Refresh TokenLong (days/weeks)Obtain new access tokensHttpOnly cookie / Secure storage

The refresh token is presented to the Authorization Server (not your API) to get a new access token. This model means your API never sees long-lived credentials, and access can be revoked the moment a refresh token is invalidated.

2.4 When golang-jwt Isn't Enough: The JOSE Ecosystem

The access/refresh pattern above assumes a single IDP and a library you control. At platform scale, that assumption breaks down quickly. Platform engineering at scale often demands:

  • Multi-tenant API gateways validating tokens from multiple upstream IDPs, each with their own JWKS endpoint
  • You own the key rotation lifecycle, needing programmatic JWKS management
  • JWE (JSON Web Encryption) to transmit encrypted, not just signed, claims
  • The full JOSE spec (JWS, JWE, JWK, JWA) under one library

In these cases, lestrrat-go/jwx gives you the full JOSE stack under one import.

package main
 
import (
	"context"
	"fmt"
	"net/http"
	"strings"
	"time"
 
	"github.com/lestrrat-go/jwx/v2/jwk"
	"github.com/lestrrat-go/jwx/v2/jwt"
)
 
// NewJWKSCache creates a cached JWKS fetcher with automatic background refresh.
// In a multi-tenant gateway, create one cache per tenant IDP JWKS endpoint.
func NewJWKSCache(ctx context.Context, jwksURL string) (*jwk.Cache, error) {
	cache := jwk.NewCache(ctx)
 
	// Register the JWKS URL; the cache polls and refreshes it in the background.
	if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil {
		return nil, fmt.Errorf("failed to register jwks url: %w", err)
	}
 
	// Pre-warm: fetch once at startup so the first request never blocks on network I/O.
	if _, err := cache.Refresh(ctx, jwksURL); err != nil {
		return nil, fmt.Errorf("failed initial jwks fetch: %w", err)
	}
 
	return cache, nil
}
 
func jwtMiddlewareWithJWKS(cache *jwk.Cache, jwksURL string, next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")
		if !strings.HasPrefix(authHeader, "Bearer ") {
			http.Error(w, "Forbidden", http.StatusForbidden)
			return
		}
		tokenString := strings.TrimPrefix(authHeader, "Bearer ")
 
		// Zero network I/O on the hot path. Keys come from the in-memory cache.
		keySet, err := cache.Get(r.Context(), jwksURL)
		if err != nil {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}
 
		// WithValidate(true) enforces exp, nbf, and iat checks in a single call,
		// equivalent to what golang-jwt handles implicitly during Parse.
		_, err = jwt.Parse(
			[]byte(tokenString),
			jwt.WithKeySet(keySet),
			jwt.WithValidate(true),
		)
		if err != nil {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
 
		next.ServeHTTP(w, r)
	}
}
package main
 
import (
	"context"
	"fmt"
	"net/http"
	"strings"
	"time"
 
	"github.com/lestrrat-go/jwx/v2/jwk"
	"github.com/lestrrat-go/jwx/v2/jwt"
)
 
// NewJWKSCache creates a cached JWKS fetcher with automatic background refresh.
// In a multi-tenant gateway, create one cache per tenant IDP JWKS endpoint.
func NewJWKSCache(ctx context.Context, jwksURL string) (*jwk.Cache, error) {
	cache := jwk.NewCache(ctx)
 
	// Register the JWKS URL; the cache polls and refreshes it in the background.
	if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil {
		return nil, fmt.Errorf("failed to register jwks url: %w", err)
	}
 
	// Pre-warm: fetch once at startup so the first request never blocks on network I/O.
	if _, err := cache.Refresh(ctx, jwksURL); err != nil {
		return nil, fmt.Errorf("failed initial jwks fetch: %w", err)
	}
 
	return cache, nil
}
 
func jwtMiddlewareWithJWKS(cache *jwk.Cache, jwksURL string, next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")
		if !strings.HasPrefix(authHeader, "Bearer ") {
			http.Error(w, "Forbidden", http.StatusForbidden)
			return
		}
		tokenString := strings.TrimPrefix(authHeader, "Bearer ")
 
		// Zero network I/O on the hot path. Keys come from the in-memory cache.
		keySet, err := cache.Get(r.Context(), jwksURL)
		if err != nil {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}
 
		// WithValidate(true) enforces exp, nbf, and iat checks in a single call,
		// equivalent to what golang-jwt handles implicitly during Parse.
		_, err = jwt.Parse(
			[]byte(tokenString),
			jwt.WithKeySet(keySet),
			jwt.WithValidate(true),
		)
		if err != nil {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
 
		next.ServeHTTP(w, r)
	}
}
Use CaseLibrary
Single IDP, symmetric secret (HS256)golang-jwt/jwt
Single IDP, asymmetric (RS256/ES256)golang-jwt/jwt
Multi-tenant gateway, multiple IDPslestrrat-go/jwx
Own JWKS rotation, JWE, full JOSElestrrat-go/jwx

3. OAuth 2.0 & OIDC: The Pair People Keep Mixing Up

OAuth 2.0 is not an authentication protocol. It is an authorization framework.

That is not a pedantic distinction. It is the line that stops teams from building confused login flows and token handling bugs.

OAuth 2.0 answers one question: what is this client allowed to access?

OIDC answers a different question: who just signed in?

RFC 6749 is explicit here. OAuth 2.0 is an authorisation framework, and user authentication at the authorisation server sits outside the spec. The confusion comes from the fact that a user usually does authenticate during an OAuth flow. Teams then grab the access token and treat it as proof of identity. That shortcut caused real problems in the early "Sign in with X" era.

OpenID Connect (OIDC) exists to close that gap. It is an identity layer on top of OAuth 2.0, standardised by the OpenID Foundation. The key addition is the ID Token.

OAuth 2.0OpenID Connect
PurposeAuthorization (what can this app do?)Authentication (who is this user?)
SpecRFC 6749OpenID Foundation Core 1.0
TokenAccess Token (opaque or JWT)ID Token (always a JWT) + Access Token
Answers“App X is allowed to read user Y’s calendar”“This request was made by user Y (email, name, sub)”

Put simply: OAuth 2.0 gives an application permission to act. OIDC adds a standard identity assertion about the user behind that session.

3.1. OAuth 2.0: How Delegated Access Works

OAuth 2.0 defines four roles:

  • Resource Owner: The user
  • Client: The application requesting access
  • Authorization Server: authenticates the user and issues tokens
  • Resource Server: The API that holds the protected resources

For modern web, SPA, and mobile applications, the standard baseline is the authorization code flow with PKCE.

The code_challenge/code_verifier PKCE extension (defined in RFC 7636) is mandatory for public clients such as SPAs and mobile apps. Without it, an intercepted authorization code can be exchanged for tokens by an attacker. PKCE makes sure only the original requestor can complete the exchange. OAuth 2.1 folds PKCE into the core spec for public clients, so treat it as baseline behaviour, not extra hardening.

3.2. OIDC: Authentication on Top of OAuth 2.0

When the openid scope is included in the authorization request, the Authorization Server returns an ID Token alongside the Access Token. The ID Token is a JWT and carries claims about the authenticated user.

{
  "iss": "https://accounts.google.com",
  "sub": "110169484474386276334",
  "email": "emre@example.com",
  "name": "Emre Cavunt",
  "iat": 1711900000,
  "exp": 1711903600,
  "aud": "your-client-id.apps.googleusercontent.com"
}
{
  "iss": "https://accounts.google.com",
  "sub": "110169484474386276334",
  "email": "emre@example.com",
  "name": "Emre Cavunt",
  "iat": 1711900000,
  "exp": 1711903600,
  "aud": "your-client-id.apps.googleusercontent.com"
}

Key claims to understand:

  1. iss (Issuer): who created and signed this token. This is your trust anchor.
  2. sub (Subject): the stable, unique user ID. Use this as your primary key, not email. In a multi IDP system, sub is scoped to the issuer, so the durable key is (iss, sub).
  3. aud (Audience): which client this token was issued for. This prevents token substitution attacks.
  4. Standard profile claims: email, name, picture, locale.
  5. nonce: a value generated by the client and included in the original authorisation request. The authorisation server embeds it in the ID token, and the client checks that it matches on the way back. That binds the ID token to the request that created it.

For system design, the practical rule is simple: use (iss, sub) as the durable identity key. Email addresses change. Issuer scoped subject identifiers usually do not.

Validating OAuth 2.0 Access Tokens on the Resource Server (Golang)

In an OIDC architecture, the client (RP) validates the ID Token, while your API (Resource Server) validates the Access Token. Building on the JWKS caching pattern from the Token-Based Authentication section, here is a purpose-built OIDCValidator that layers in issuer and audience checks for access-token validation. Every OIDC-compliant provider exposes its JWKS at a well-known URL derived from its discovery document (/.well-known/openid-configuration -> jwks_uri).

package main
 
import (
	"context"
	"fmt"
	"net/http"
	"strings"
	"time"
 
	"github.com/lestrrat-go/jwx/v2/jwk"
	"github.com/lestrrat-go/jwx/v2/jwt"
)
 
// OIDCValidator validates JWT access tokens from a specific OIDC provider.
type OIDCValidator struct {
	cache    *jwk.Cache
	jwksURL  string
	issuer   string
	audience string
}
 
// NewOIDCValidator bootstraps an OIDC validator for a given provider.
// jwksURL comes from the provider's /.well-known/openid-configuration → "jwks_uri":
//
//	Google:      "https://www.googleapis.com/oauth2/v3/certs"
//	Okta:        "https://dev-xxxx.okta.com/oauth2/default/v1/keys"
//	Cognito:     "https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/jwks.json"
//	Keycloak:    "https://keycloak.example.com/realms/{realm}/protocol/openid-connect/certs"
//	Entra ID:    "https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
func NewOIDCValidator(ctx context.Context, jwksURL, issuer, audience string) (*OIDCValidator, error) {
	cache := jwk.NewCache(ctx)
 
	if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(10*time.Minute)); err != nil {
		return nil, fmt.Errorf("jwks registration failed: %w", err)
	}
 
	// Pre-warm the cache: the first request should never pay a cold-fetch penalty.
	if _, err := cache.Refresh(ctx, jwksURL); err != nil {
		return nil, fmt.Errorf("initial jwks fetch failed: %w", err)
	}
 
	return &OIDCValidator{cache: cache, jwksURL: jwksURL, issuer: issuer, audience: audience}, nil
}
 
// contextKey is an unexported type for context keys scoped to this package,
// preventing collisions with keys defined in other packages.
type contextKey string
 
const claimsKey contextKey = "oidc_claims"
 
// Claims holds the validated identity context extracted from the access token.
type Claims struct {
	Subject string
	Email   string
	Name    string
}
 
// Middleware validates the Bearer access token, extracts verified claims,
// and injects them into the request context for downstream handlers.
func (v *OIDCValidator) Middleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")
		if !strings.HasPrefix(authHeader, "Bearer ") {
			http.Error(w, "missing or malformed Authorization header", http.StatusUnauthorized)
			return
		}
		rawToken := strings.TrimPrefix(authHeader, "Bearer ")
 
		// In-memory key retrieval. Zero network I/O on the hot path.
		keySet, err := v.cache.Get(r.Context(), v.jwksURL)
		if err != nil {
			http.Error(w, "failed to retrieve signing keys", http.StatusInternalServerError)
			return
		}
 
		// Full validation: signature + exp/nbf/iat + iss + aud.
		// CRITICAL: always validate iss and aud. A token could be legitimately
		// signed but intended for a different client or issued by a different
		// IDP. Cross-tenant token substitution is a real attack vector.
		token, err := jwt.Parse(
			[]byte(rawToken),
			jwt.WithKeySet(keySet),
			jwt.WithValidate(true),
			jwt.WithIssuer(v.issuer),
			jwt.WithAudience(v.audience),
		)
		if err != nil {
			// Log the detailed error server-side for debugging, but return a
			// generic message to the client. Leaking validation errors can expose
			// JWKS endpoint URLs, key IDs, or token structure to attackers.
			// log.Printf("token validation failed: %v", err)
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
 
		// Extract claims and inject into request context.
		// Use sub as the stable user identifier. It is unique per (iss, sub),
		// not globally, so always carry both if operating across multiple IDPs.
		email, _ := token.Get("email")
		name, _ := token.Get("name")
 
		claims := Claims{
			Subject: token.Subject(),
			Email:   fmt.Sprintf("%v", email),
			Name:    fmt.Sprintf("%v", name),
		}
 
		ctx := context.WithValue(r.Context(), claimsKey, claims)
		next.ServeHTTP(w, r.WithContext(ctx))
	}
}
 
// ClaimsFromContext retrieves validated OIDC claims from the request context.
// Returns false if claims are not present. Handlers should treat this as an auth failure.
func ClaimsFromContext(ctx context.Context) (Claims, bool) {
	claims, ok := ctx.Value(claimsKey).(Claims)
	return claims, ok
}
package main
 
import (
	"context"
	"fmt"
	"net/http"
	"strings"
	"time"
 
	"github.com/lestrrat-go/jwx/v2/jwk"
	"github.com/lestrrat-go/jwx/v2/jwt"
)
 
// OIDCValidator validates JWT access tokens from a specific OIDC provider.
type OIDCValidator struct {
	cache    *jwk.Cache
	jwksURL  string
	issuer   string
	audience string
}
 
// NewOIDCValidator bootstraps an OIDC validator for a given provider.
// jwksURL comes from the provider's /.well-known/openid-configuration → "jwks_uri":
//
//	Google:      "https://www.googleapis.com/oauth2/v3/certs"
//	Okta:        "https://dev-xxxx.okta.com/oauth2/default/v1/keys"
//	Cognito:     "https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/jwks.json"
//	Keycloak:    "https://keycloak.example.com/realms/{realm}/protocol/openid-connect/certs"
//	Entra ID:    "https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
func NewOIDCValidator(ctx context.Context, jwksURL, issuer, audience string) (*OIDCValidator, error) {
	cache := jwk.NewCache(ctx)
 
	if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(10*time.Minute)); err != nil {
		return nil, fmt.Errorf("jwks registration failed: %w", err)
	}
 
	// Pre-warm the cache: the first request should never pay a cold-fetch penalty.
	if _, err := cache.Refresh(ctx, jwksURL); err != nil {
		return nil, fmt.Errorf("initial jwks fetch failed: %w", err)
	}
 
	return &OIDCValidator{cache: cache, jwksURL: jwksURL, issuer: issuer, audience: audience}, nil
}
 
// contextKey is an unexported type for context keys scoped to this package,
// preventing collisions with keys defined in other packages.
type contextKey string
 
const claimsKey contextKey = "oidc_claims"
 
// Claims holds the validated identity context extracted from the access token.
type Claims struct {
	Subject string
	Email   string
	Name    string
}
 
// Middleware validates the Bearer access token, extracts verified claims,
// and injects them into the request context for downstream handlers.
func (v *OIDCValidator) Middleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")
		if !strings.HasPrefix(authHeader, "Bearer ") {
			http.Error(w, "missing or malformed Authorization header", http.StatusUnauthorized)
			return
		}
		rawToken := strings.TrimPrefix(authHeader, "Bearer ")
 
		// In-memory key retrieval. Zero network I/O on the hot path.
		keySet, err := v.cache.Get(r.Context(), v.jwksURL)
		if err != nil {
			http.Error(w, "failed to retrieve signing keys", http.StatusInternalServerError)
			return
		}
 
		// Full validation: signature + exp/nbf/iat + iss + aud.
		// CRITICAL: always validate iss and aud. A token could be legitimately
		// signed but intended for a different client or issued by a different
		// IDP. Cross-tenant token substitution is a real attack vector.
		token, err := jwt.Parse(
			[]byte(rawToken),
			jwt.WithKeySet(keySet),
			jwt.WithValidate(true),
			jwt.WithIssuer(v.issuer),
			jwt.WithAudience(v.audience),
		)
		if err != nil {
			// Log the detailed error server-side for debugging, but return a
			// generic message to the client. Leaking validation errors can expose
			// JWKS endpoint URLs, key IDs, or token structure to attackers.
			// log.Printf("token validation failed: %v", err)
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
 
		// Extract claims and inject into request context.
		// Use sub as the stable user identifier. It is unique per (iss, sub),
		// not globally, so always carry both if operating across multiple IDPs.
		email, _ := token.Get("email")
		name, _ := token.Get("name")
 
		claims := Claims{
			Subject: token.Subject(),
			Email:   fmt.Sprintf("%v", email),
			Name:    fmt.Sprintf("%v", name),
		}
 
		ctx := context.WithValue(r.Context(), claimsKey, claims)
		next.ServeHTTP(w, r.WithContext(ctx))
	}
}
 
// ClaimsFromContext retrieves validated OIDC claims from the request context.
// Returns false if claims are not present. Handlers should treat this as an auth failure.
func ClaimsFromContext(ctx context.Context) (Claims, bool) {
	claims, ok := ctx.Value(claimsKey).(Claims)
	return claims, ok
}

Key Takeaways

  1. OAuth 2.0 is authorisation, not authentication. Never use a raw access token to identify a user.
  2. OIDC adds the ID Token, which is the signed identity assertion your client can verify.
  3. Always validate iss and aud. A legitimately signed token from the wrong issuer or for the wrong audience is still invalid for your API.
  4. Your durable identity key is (iss, sub). Not email, and not sub on its own.
  5. PKCE is baseline. Treat it as required behaviour for public clients, not an optional hardening step.

4. SSO & Identity Protocols

4.1. What is SSO? (Hint: It's a User Experience, Not a Protocol)

Single Sign On (SSO) is often described as an authentication method. It is not. SSO is a user experience pattern: sign in once, move across multiple applications without another login prompt. The user authenticates with a central Identity Provider (IdP), and the applications, often called Service Providers (SPs) or Relying Parties (RPs), rely on that session instead of asking again.

The two protocols you will see most often are SAML 2.0 and OIDC. Same user outcome, very different implementation model.

4.2. SAML 2.0: The Enterprise Standard

Security Assertion Markup Language (SAML) 2.0 is an XML-based open standard ratified by OASIS in March 2005. It defines how an IdP can securely pass authentication and authorization information to a Service Provider.

This is the SP initiated flow, which is the most common SAML pattern. SAML also supports IdP initiated flow, where the IdP sends an assertion without a prior AuthnRequest. You will still see that in legacy enterprise integrations, but it is weaker from a request binding perspective and needs careful replay protection and short assertion validity windows.

On the wire, SAML is XML, typically base64 encoded and URL encoded for transport over HTTP Redirect or HTTP POST bindings. The identity payload is a SAML Assertion: a signed XML document containing user attributes such as NameID, email, and group membership.

SAML is still the lingua franca of enterprise SSO. If you are integrating your SaaS with customers running Microsoft AD FS, Entra ID, Okta, PingIdentity, or OneLogin, you will encounter it. It was designed for browser redirect flows between enterprise systems, and it feels like it.

Platform Engineer Insight: SAML's XML canonicalization rules have burned more than one engineering team who assumed they could handle it by hand. Serialization differences between XML libraries can silently break signature verification in ways that are genuinely painful to debug. Use crewjam/saml in Go. You will not enjoy the alternative.

4.3. OIDC as an SSO Protocol: The Modern Approach

OIDC was introduced in Section 3 as the authentication layer on top of OAuth 2.0. It also works extremely well as an SSO protocol for modern applications. "Sign in with Google," "Log in with Okta," and "Continue with Microsoft" are just OIDC showing up in product form. The IdP acts as the OIDC Provider (OP), your application is the Relying Party (RP), and the usual flow is authorisation code + PKCE with openid in the scope so the provider returns an ID Token.

The contrast with SAML is obvious in day to day implementation. SAML usually delivers signed XML assertions through the browser and often needs ACS endpoints, XML canonicalisation, and metadata exchange. OIDC is built on OAuth 2.0 and usually carries identity in a signed JWT ID token obtained through standard HTTP based flows. You still need redirect URIs, client configuration, and proper token validation against the provider's JWKS, but you avoid most of the XML heavy pain. The identity payload usually includes a stable sub claim, with email and name available depending on scopes and provider configuration.

When to choose which:

DimensionSAML 2.0OIDC
EraEnterprise (circa 2005)Modern web & mobile (circa 2014)
FormatXMLJSON / JWT
ComplexityHigher, XML signing, ACS URLs, metadata filesLower, standard OAuth 2.0 flows
Mobile supportPoor (browser redirect dependent)Native (works with any HTTP client)
Primary usersEnterprise SaaS, corporate SSO (Office 365, Salesforce)Consumer apps, modern SaaS, cloud-native APIs
IdP examplesAD FS, Entra ID (supports both), Okta (legacy), PingGoogle, Okta (modern), Auth0, Keycloak, Cognito, Entra ID (supports both)

Entra ID appears in both columns deliberately because it supports SAML and OIDC, and enterprise customers will use whichever their IT policy dictates. When you have a choice, push for OIDC.

The practical rule is blunt: if your customer's IT team mentions "Active Directory" and "SAML" in the same sentence, implement SAML. For new products, consumer apps, microservices, and mobile, default to OIDC.


5. mTLS: Zero Trust Service to Service Identity

Everything above deals with user identity. But when one service calls another, for example payment-service calling fraud-service, there may be no human user in that hop. You still need both sides to prove who they are. That is where mutual TLS (mTLS) fits.

mTLS is a form of TLS where both sides authenticate each other with certificates, not just the server. In regular TLS, the client verifies the server certificate. In mTLS, the server also verifies the client certificate. You get encryption in transit plus mutual authentication in the same handshake. The organisation running mTLS acts as its own certificate authority. A root CA issues short lived certificates to each workload, and every connection requires both parties to present a valid cert. No certificate, no connection.

In practice, mTLS is commonly used for:

  1. service to service communication in distributed systems
  2. Zero Trust networking, where internal traffic is not trusted by default
  3. API and workload identity, where you want strong machine identity instead of shared secrets

You usually do not want every application team managing certificates manually in code. In Kubernetes platforms, mTLS is often handled by the infrastructure layer instead, for example by a service mesh such as Istio or Linkerd, which can establish mTLS between workloads and rotate certificates for you. Standards like SPIFFE give each workload a stable cryptographic identity independent of IP address, which is exactly what you need when pods are ephemeral.

When to use it: Service-to-service traffic inside platforms where you want strong workload identity, encrypted internal traffic, and Zero Trust defaults. It is especially useful in Kubernetes and microservice environments.


Conclusion

Authentication gets simpler once you stop looking for one best method.

Basic Auth, API keys, and sessions still have their place. Bearer tokens and JWTs help APIs scale without a lookup on every request. OAuth 2.0 handles delegated access. OIDC layers identity on top. SAML still runs a huge slice of enterprise SSO. mTLS solves a separate problem entirely: workload identity between machines.

The better question is not, "Which authentication method is best?" It is, "What problem am I solving at this layer?"

Am I authenticating a user? Granting an application access to a resource? Enabling SSO across multiple systems? Proving the identity of one workload to another?

Once you ask the right question, the tool choice usually gets much easier.

That is why modern authentication is layered. Sessions, tokens, federation protocols, and mTLS are not competing answers to the same problem. They are different tools for different trust boundaries.