Identity Federation, Token-Based Authentication, and Enterprise-Grade SSO From Zero to Production
1. Introduction & Why Auth is Hard
Authentication and authorization are the bedrock of every modern application. Yet they remain two of the most misunderstood, misimplemented, and costly-to-migrate components in software engineering. A poorly designed auth system leads to security breaches, frustrated users, failed compliance audits, and expensive rewrites that can take years. Getting auth right " from the ground up " is arguably the single most impactful architectural decision a team can make.
Authentication " verifying who a user is " has evolved dramatically over the past decade. We have moved from password-based authentication with simple session cookies, through token-based systems like OAuth 1.0 and SAML, to the modern landscape dominated by OAuth 2.0, OpenID Connect, and increasingly passwordless authentication with WebAuthn and passkeys. Each evolution brought better security, improved user experience, and new complexity. The industry consensus around OAuth 2.0 as the authorization framework and OpenID Connect as the identity layer has standardized how applications handle auth across web, mobile, and API contexts.
Authorization " verifying what a user is allowed to do " has evolved in parallel. Simple role-based access control (RBAC) has given way to attribute-based access control (ABAC), relationship-based access control (ReBAC), and policy-based systems using OPA (Open Policy Agent) or Cedar. Modern authorization must handle multi-tenant isolation, fine-grained resource-level permissions, and dynamic policy evaluation that adapts to context (time of day, location, device posture, risk score).
The complexity multiplies when you introduce Single Sign-On (SSO). SSO promises users the convenience of a single login across multiple applications, but it introduces the problem of identity federation " how does Application A trust that a user authenticated by Identity Provider B is who they claim to be? This trust is established through cryptographic signatures, SAML assertions, or OpenID Connect ID tokens, each with its own token format, validation rules, and protocol quirks.
Real-World Case Studies
| Company | System | Scale | Key Innovation |
|---|---|---|---|
| Auth0 / Okta | Identity-as-a-Service | 100M+ users, millions of tenants | Universal Login, Actions (custom rules), multi-protocol |
| Google Identity Platform | Billions of accounts | One Tap, passkeys, risk-based auth, reCAPTCHA Enterprise | |
| Microsoft | Azure AD / Entra ID | 500M+ enterprise users | Conditional Access, Identity Protection, seamless SSO |
| GitHub | OAuth & GitHub Apps | 100M+ developers | Fine-grained PATs, OAuth app scopes, token expiration |
| Amazon | Cognito | Hundreds of thousands of apps | Federation across social + enterprise IdPs, adaptive auth |
Auth0's Universal Login is particularly instructive: rather than embedding login UI in each application, Auth0 hosts a centralized login page that applications redirect to. This means security updates (like adding MFA support, CAPTCHA, or passwordless options) are deployed once for the auth server, and apply to all client applications automatically. Enterprise SSO tenants get SAML or OIDC federation on top of the same login page. This architecture " the centralized authorization server " is the pattern we adopt throughout this guide.
Microsoft's Conditional Access demonstrates the power of risk-based policies: a login from a known device on the corporate network during business hours might require only a password, while a login from a new device in a different country on a weekend requires MFA and device compliance checks. The auth system must support this kind of dynamic policy evaluation " it is not enough to decide "is the password correct?" " the system must evaluate the entire context of the authentication request and apply appropriate policies.
This guide covers every aspect of designing a production-grade authentication and authorization system. We start with the fundamental distinction between authentication and authorization, then dive deep into each protocol and technology: OAuth 2.0 flows (authorization code, client credentials, device code), PKCE, OpenID Connect, SAML 2.0, JWT tokens, token rotation, session management, MFA with TOTP and WebAuthn, password hashing with Argon2id, account lockout, RBAC and ABAC, API key management, rate limiting, audit logging, social login, multi-tenant SSO, SCIM provisioning, security protections (CSRF, XSS, token theft), compliance (SOC 2, GDPR), monitoring, cost estimation, API design, and testing strategy. Each section includes production-ready C# code, Mermaid diagrams, tables, and real-world case studies.
2. Functional & Non-Functional Requirements
Functional Requirements
- User Registration & Login: Email/password, social login (Google, GitHub, Microsoft, Apple), SSO via SAML/OIDC, and passwordless options (magic links, WebAuthn).
- Multi-Factor Authentication: TOTP (time-based one-time passwords), SMS/email codes, WebAuthn (FIDO2 hardware keys, platform authenticators), and backup codes.
- Session Management: Short-lived access tokens (15 minutes), longer-lived refresh tokens (7-90 days), session revocation, and device management.
- Authorization: Role-based access control (RBAC) with hierarchical roles, attribute-based access control (ABAC) with dynamic policy evaluation, resource-level permissions.
- API Security: API key management with granular scopes, rate limiting per tenant/user/IP, audit logging of all API calls.
- User Management: Admin UI for user CRUD, group management, role assignment, account lockout/unlock, password reset.
- Enterprise SSO: SAML 2.0 and OpenID Connect federation, SCIM provisioning for user/group sync, just-in-time provisioning.
- Audit & Compliance: Immutable audit log of authentication events, admin actions, token issuance/revocation. SOC 2 and GDPR compliance support.
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Authentication Latency (P99) | < 500ms (including IdP calls) | Users abandon login flows that take too long |
| Token Validation Latency (P99) | < 10ms (local JWKS cache) | API gateways validate every request " must be fast |
| Availability | 99.99% (52 minutes downtime/year) | Auth is a critical dependency for all other services |
| Token Issuance Throughput | 100K tokens/second | Peak login demand (Monday morning, Black Friday) |
| User Scale | 10 million registered users | Mid-to-large SaaS platform |
| Tenant Scale | 100,000 tenants (multi-tenant) | Enterprise SaaS with many customers |
| Concurrent Active Sessions | 50 million | Users on multiple devices |
| Brute Force Protection | Lockout after 5 failed attempts in 15 minutes | Industry standard (OWASP recommendation) |
| Password Hash Strength | Argon2id, time cost ≥ 3, memory cost ≥ 64MB | Resistant to GPU/ASIC attacks |
| Session Timeout (idle) | 30 minutes | OWASP session management best practice |
| Audit Log Retention | 1 year hot, 7 years cold archive | SOC 2 and GDPR compliance requirements |
| Recovery Point Objective | Zero data loss | Auth data loss means permanent user lockout |
| Recovery Time Objective | < 5 minutes | Auth downtime stops all application access |
Key Design Tradeoffs
| Tradeoff | Option A | Option B | Our Choice |
|---|---|---|---|
| Token Storage | Server-side sessions (revocable, stateful) | Self-contained JWT (stateless, fast) | JWT for access tokens, DB for refresh tokens |
| Password Storage | bcrypt (well-audited, slower iteration) | Argon2id (modern, tunable) | Argon2id (PHC winner) |
| Auth Protocol | Proprietary token system | OAuth 2.0 + OIDC (standardized) | OAuth 2.0 + OIDC (interoperability) |
| SSO Protocol | SAML 2.0 (enterprise, XML-based) | OIDC (modern, JSON-based) | Both (enterprise requires SAML) |
| MFA Approach | TOTP (standard, app-based) | WebAuthn (passwordless, strong) | Both layered; WebAuthn as primary |
| Authorization Model | RBAC (simple, role hierarchy) | ABAC (fine-grained, dynamic) | RBAC by default, ABAC for complex |
3. Authentication vs Authorization " The Foundation
Authentication (AuthN) answers the question: "Who are you?" It is the process of verifying a user's identity through credentials: something you know (password), something you have (phone, hardware key), or something you are (biometric). Authentication is about establishing identity " the system confirms that the user presenting credentials is indeed who they claim to be.
Authorization (AuthZ) answers the question: "What are you allowed to do?" It is the process of determining whether an authenticated user has permission to access a specific resource or perform a specific action. Authorization always depends on authentication " you cannot authorize an unauthenticated user. But the reverse is not true: you can authenticate a user and then deny them authorization for a particular action.
This distinction is critical because different protocols and technologies handle each concern differently. OAuth 2.0 is fundamentally an authorization framework " it issues tokens that grant access to resources. OpenID Connect is an authentication layer built on top of OAuth 2.0 " it adds an ID token that proves the user's identity. SAML 2.0 handles both authentication (via assertions about user identity) and authorization (via attributes in the assertion) in a single protocol.
The Complete Auth Pipeline
The auth pipeline follows this sequence: (1) User presents credentials, (2) Authentication service validates credentials, (3) Auth service issues a token containing user identity and claims, (4) User presents token to application, (5) Application validates token, (6) Authorization service evaluates policies against token claims, (7) Access granted or denied. Each step has specific requirements for security, performance, and reliability.
A common antipattern is embedding authorization logic inside the authentication service. The auth service should authenticate and issue tokens containing identity claims (user ID, roles, groups, attributes). An independent authorization service or policy engine should evaluate those claims against resource-specific policies. This separation allows the authorization model to evolve independently of the authentication mechanism.
4. OAuth 2.0 Deep Dive " Authorization Framework
OAuth 2.0 (RFC 6749) is the industry-standard protocol for authorization. It enables applications to obtain limited access to user accounts on an HTTP service. It works by delegating user authentication to the service that hosts the user account, and authorizing third-party applications to access that user's data. OAuth 2.0 provides authorization flows for web, desktop, mobile, and server-side applications.
OAuth 2.0 defines four roles: the Resource Owner (the user who owns the data), the Resource Server (the API that hosts the data), the Client (the application requesting access), and the Authorization Server (the server that issues tokens). The authorization flow involves the client requesting authorization from the resource owner, obtaining an authorization grant, exchanging it for an access token at the authorization server, and using the access token to access the resource server.
Authorization Code Flow (Most Secure)
The Authorization Code flow is the most secure OAuth 2.0 flow and is recommended for server-side web applications. The client never sees the user's credentials " it receives an authorization code after the user authenticates, then exchanges that code for an access token using a client secret. This two-step process ensures that the access token is never exposed to the user's browser or any intermediate party.
Client Credentials Flow (Machine-to-Machine)
The Client Credentials flow is used for server-to-server communication where no user is involved. The client authenticates directly with the authorization server using its client ID and client secret (or a signed JWT assertion) and receives an access token. This flow is commonly used for backend services, cron jobs, and API integrations. The token scope is determined by the client's permissions, not a user's permissions.
C#
public class ClientCredentialsService
{
private readonly HttpClient _client;
private readonly TokenCache _cache = new();
private readonly SemaphoreSlim _semaphore = new(1, 1);
public ClientCredentialsService(HttpClient client) { _client = client; }
public async Task<string> GetTokenAsync(string endpoint, string id, string secret, string[] scopes)
{
var cacheKey = $"{id}:{string.Join(",", scopes)}";
if (_cache.TryGet(cacheKey, out string cachedToken)) return cachedToken;
await _semaphore.WaitAsync();
try
{
if (_cache.TryGet(cacheKey, out cachedToken)) return cachedToken;
var req = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "client_credentials"),
new KeyValuePair<string, string>("client_id", id),
new KeyValuePair<string, string>("client_secret", secret),
new KeyValuePair<string, string>("scope", string.Join(" ", scopes))
})
};
var resp = await _client.SendAsync(req);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<TokenResponse>();
_cache.Add(cacheKey, json.AccessToken, TimeSpan.FromSeconds(json.ExpiresIn - 60));
return json.AccessToken;
}
finally { _semaphore.Release(); }
}
}
public record TokenResponse(string AccessToken, int ExpiresIn, string TokenType);
Device Authorization Flow (Input-Constrained Devices)
The Device Authorization flow (RFC 8628) is designed for devices with limited input capabilities " smart TVs, IoT devices, CLI tools, and game consoles. The device shows a code and URL, the user visits that URL on another device to authenticate, and the device polls the authorization server until the user completes authentication. This flow is increasingly important as IoT and streaming devices proliferate.
C#
public class DeviceAuthorizationFlow
{
private readonly HttpClient _http;
private readonly string _deviceAuthEndpoint;
private readonly string _tokenEndpoint;
public async Task StartAsync(string clientId, CancellationToken ct)
{
// Step 1: Request device code
var req = new HttpRequestMessage(HttpMethod.Post, _deviceAuthEndpoint)
{
Content = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("client_id", clientId),
new KeyValuePair<string, string>("scope", "openid profile email")
})
};
var resp = await _http.SendAsync(req, ct);
resp.EnsureSuccessStatusCode();
var deviceResp = await resp.Content.ReadFromJsonAsync<DeviceAuthResponse>(ct);
Console.WriteLine($"Visit {deviceResp.VerificationUri}");
Console.WriteLine($"Enter code: {deviceResp.UserCode}");
// Step 2: Poll for token
while (!ct.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(deviceResp.Interval), ct);
var tokenResp = await _http.PostAsync(_tokenEndpoint,
new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
new KeyValuePair<string, string>("device_code", deviceResp.DeviceCode),
new KeyValuePair<string, string>("client_id", clientId)
}), ct);
if (tokenResp.IsSuccessStatusCode)
{
var token = await tokenResp.Content.ReadFromJsonAsync<TokenResponse>(ct);
Console.WriteLine($"Token: {token.AccessToken}");
return;
}
var error = await tokenResp.Content.ReadFromJsonAsync<OAuthError>(ct);
if (error?.Error == "access_denied") throw new Exception("User denied");
if (error?.Error == "expired_token") throw new Exception("Code expired");
// authorization_pending -> continue polling
}
}
}
OAuth 2.0 Grant Type Comparison
| Grant Type | Use Case | Secret Needed | Refresh Token | Security |
|---|---|---|---|---|
| Authorization Code | Server-side web apps | Yes | Yes | Highest |
| Authorization Code + PKCE | Mobile, SPA, Native | No | Yes | High |
| Client Credentials | M2M, backend services | Yes | No | High |
| Device Authorization | TVs, CLI, IoT | Optional | Yes | Medium |
| Implicit (Deprecated) | SPA (legacy) | No | No | Low |
| Password (Deprecated) | Legacy | Yes | Yes | Low |
5. PKCE " Securing Public Clients
Proof Key for Code Exchange (PKCE, pronounced "pixy") is an OAuth 2.0 extension (RFC 7636) that prevents authorization code interception attacks. Public clients (mobile apps, SPAs, native applications) cannot store secrets securely " an attacker can reverse-engineer the app and extract embedded secrets. Without PKCE, an attacker who intercepts the authorization code (via a compromised redirect URI, man-in-the-middle on mobile networks, or malicious app on the same device) can exchange it for an access token.
The PKCE flow: (1) The client generates a random code_verifier. (2) The client computes the SHA-256 hash to produce the code_challenge. (3) During authorization, the client sends the code_challenge and method (S256). (4) When exchanging the code for tokens, the client sends the original code_verifier. (5) The server hashes the verifier and compares it with the stored challenge. If they match, the exchange proceeds. An attacker who intercepts the code cannot complete the exchange without the verifier.
C#
public class PkceService
{
private const int VerifierBytes = 64;
private static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create();
public (string verifier, string challenge) GeneratePKCE()
{
var bytes = new byte[VerifierBytes];
Rng.GetBytes(bytes);
var verifier = Base64UrlEncode(bytes);
var challenge = Base64UrlEncode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
return (verifier, challenge);
}
public string BuildAuthorizationUrl(string authorizeEndpoint, string clientId,
string redirectUri, string challenge, string state, string scope)
{
var query = QueryHelpers.AddQueryString(authorizeEndpoint, new Dictionary<string, string>
{
["response_type"] = "code", ["client_id"] = clientId,
["redirect_uri"] = redirectUri, ["scope"] = scope,
["state"] = state, ["code_challenge"] = challenge,
["code_challenge_method"] = "S256"
});
return query;
}
public async Task<TokenResponse> ExchangeCodeAsync(string tokenEndpoint,
string clientId, string redirectUri, string code, string verifier)
{
var resp = await new HttpClient().PostAsync(tokenEndpoint,
new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "authorization_code"),
new KeyValuePair<string, string>("code", code),
new KeyValuePair<string, string>("client_id", clientId),
new KeyValuePair<string, string>("redirect_uri", redirectUri),
new KeyValuePair<string, string>("code_verifier", verifier)
}));
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadFromJsonAsync<TokenResponse>();
}
private static string Base64UrlEncode(byte[] data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
6. OpenID Connect " Identity on Top of OAuth
OpenID Connect (OIDC) is an authentication layer built on top of OAuth 2.0. Defined by the OpenID Foundation, OIDC adds an ID token " a JSON Web Token (JWT) that contains claims about the authenticated user's identity. OIDC standardizes how clients obtain and verify user identity, providing a simple, RESTful approach to authentication that works across web, mobile, and native applications.
OIDC introduces three key elements: the openid scope (signals the client wants an ID token), the ID Token (a signed JWT containing identity claims like sub, iss, aud, exp, iat, and optional profile claims), and the UserInfo endpoint (a standardized REST API for fetching additional user profile claims). OIDC also defines discovery metadata at /.well-known/openid-configuration.
C#
public class OidcAuthenticationHandler
{
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configManager;
public OidcAuthenticationHandler(string authority)
{
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
$"{authority}/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever(),
new HttpClient())
{
AutomaticRefreshInterval = TimeSpan.FromHours(1)
};
}
public async Task<ClaimsPrincipal> ValidateIdTokenAsync(string idToken, string clientId)
{
var config = await _configManager.GetConfigurationAsync();
var handler = new JsonWebTokenHandler();
var result = await handler.ValidateTokenAsync(idToken, new TokenValidationParameters
{
ValidIssuer = config.Issuer,
ValidAudience = clientId,
IssuerSigningKeys = config.SigningKeys,
ValidateIssuer = true, ValidateAudience = true,
ValidateLifetime = true, ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.FromMinutes(2)
});
if (!result.IsValid)
throw new SecurityTokenException($"Token validation failed: {result.Exception?.Message}");
return new ClaimsPrincipal(result.ClaimsIdentity);
}
public async Task<UserInfo> FetchUserInfoAsync(string authority, string accessToken)
{
var config = await _configManager.GetConfigurationAsync();
var req = new HttpRequestMessage(HttpMethod.Get, config.UserInfoEndpoint);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var resp = await new HttpClient().SendAsync(req);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<UserInfo>();
return json;
}
}
public record UserInfo(
[property: JsonPropertyName("sub")] string Subject,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("email")] string? Email,
[property: JsonPropertyName("email_verified")] bool? EmailVerified,
[property: JsonPropertyName("picture")] string? Picture);
OIDC vs SAML 2.0
| Feature | OpenID Connect | SAML 2.0 |
|---|---|---|
| Token Format | JWT (JSON, compact, web-friendly) | SAML Assertion (XML, verbose) |
| Transport | HTTP REST (JSON over HTTPS) | HTTP POST Redirect, SOAP bindings |
| Discovery | /.well-known/openid-configuration | Federation metadata XML (larger) |
| Mobile/SPA Support | Excellent (native JSON parsing) | Poor (complex XML parsing on mobile) |
| API Integration | Natural (access tokens + ID tokens) | Awkward (SAML assertions for API auth) |
| Maturity | 2014 (widely adopted) | 2005 (deep enterprise penetration) |
| Enterprise Support | Growing (Azure AD, Okta, Google) | Deep (ADFS, Okta, OneLogin, Shibboleth) |
| Logout (SLO) | Session management + RP-initiated logout | Standardized SLO with backchannel |
| Attribute Exchange | UserInfo endpoint (claims) | SAML attribute statements |
7. SAML 2.0 " Enterprise SSO Protocol
SAML 2.0 is an XML-based standard for exchanging authentication and authorization data between security domains. SAML is the dominant protocol for enterprise Single Sign-On " it is how employees log into hundreds of SaaS applications using their corporate credentials. Despite its age (2005), SAML remains a mandatory protocol for any enterprise-facing auth system.
SAML defines three roles: the Principal (user), the Identity Provider (IdP, e.g., Azure AD, Okta, ADFS), and the Service Provider (SP, the application). The flow: user attempts to access an SP resource, SP generates an AuthnRequest and redirects the user to the IdP, the user authenticates at the IdP, the IdP generates a SAML assertion and POSTs it to the SP's ACS URL, and the SP validates the assertion and establishes a session.
SAML Assertion Validation
C#
public class SamlValidator
{
private readonly X509Certificate2 _idpCert;
private readonly string _expectedAudience;
private readonly string _expectedIssuer;
private readonly TimeSpan _clockSkew = TimeSpan.FromMinutes(3);
public SamlValidator(X509Certificate2 cert, string audience, string issuer)
{
_idpCert = cert; _expectedAudience = audience; _expectedIssuer = issuer;
}
public (string nameId, IReadOnlyDictionary<string, string> attributes) Validate(string samlResponseXml)
{
var doc = new XmlDocument { PreserveWhitespace = true };
doc.LoadXml(samlResponseXml);
var ns = new XmlNamespaceManager(doc.NameTable);
ns.AddNamespace("saml2p", "urn:oasis:names:tc:SAML:2.0:protocol");
ns.AddNamespace("saml2", "urn:oasis:names:tc:SAML:2.0:assertion");
ns.AddNamespace("ds", "http://www.w3.org/2000/09/xmldsig#");
// Verify XML Digital Signature
var signedXml = new SignedXml(doc);
var sigNode = doc.SelectSingleNode("//ds:Signature", ns);
if (sigNode == null) throw new Exception("Missing signature");
signedXml.LoadXml(sigNode as XmlElement);
if (!signedXml.CheckSignature(_idpCert, true))
throw new Exception("Invalid signature");
// Extract assertion
var assertion = doc.SelectSingleNode("//saml2:Assertion", ns);
if (assertion == null) throw new Exception("Missing assertion");
// Validate issuer
var issuer = assertion.SelectSingleNode("saml2:Issuer", ns)?.InnerText;
if (issuer != _expectedIssuer) throw new Exception($"Issuer mismatch: {issuer}");
// Validate audience
var audience = assertion.SelectSingleNode("//saml2:Audience", ns)?.InnerText;
if (audience != _expectedAudience) throw new Exception($"Audience mismatch: {audience}");
// Validate timestamps
var conditions = assertion.SelectSingleNode("saml2:Conditions", ns);
if (conditions != null)
{
var notBefore = DateTime.Parse(conditions.Attributes["NotBefore"].Value);
var notOnOrAfter = DateTime.Parse(conditions.Attributes["NotOnOrAfter"].Value);
var now = DateTime.UtcNow;
if (now < notBefore.Add(-_clockSkew)) throw new Exception("Assertion not yet valid");
if (now >= notOnOrAfter.Add(_clockSkew)) throw new Exception("Assertion expired");
}
// Extract attributes
var attrs = new Dictionary<string, string>();
var attrNodes = assertion.SelectNodes("//saml2:Attribute", ns);
if (attrNodes != null)
foreach (XmlNode attr in attrNodes)
{
var name = attr.Attributes["Name"].Value;
var value = attr.SelectSingleNode("saml2:AttributeValue", ns)?.InnerText;
if (value != null) attrs[name] = value;
}
var nameId = assertion.SelectSingleNode("//saml2:NameID", ns)?.InnerText
?? throw new Exception("Missing NameID");
return (nameId, attrs);
}
}
8. JWT " Access & Refresh Tokens
JSON Web Tokens (JWT, RFC 7519) are the backbone of modern token-based authentication. A JWT consists of three base64url-encoded parts separated by dots: the header (signing algorithm + key ID), the payload (claims), and the signature (cryptographic protection). Access tokens are short-lived bearer tokens (15 minutes by default); refresh tokens are long-lived credentials used to obtain new access tokens without requiring the user to re-authenticate.
C#
public class TokenService
{
private readonly SigningCredentials _credentials;
private readonly string _issuer, _audience;
private readonly TimeSpan _accessTtl = TimeSpan.FromMinutes(15);
private readonly TimeSpan _refreshTtl = TimeSpan.FromDays(90);
private readonly IRefreshTokenStore _rtStore;
private static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create();
public TokenService(string issuer, string audience, SigningCredentials creds, IRefreshTokenStore rtStore)
{
_issuer = issuer; _audience = audience; _credentials = creds; _rtStore = rtStore;
}
public async Task<(string accessToken, string refreshToken, DateTime expires)>
IssueTokensAsync(string userId, string tenantId, string[] roles, string sessionId)
{
var now = DateTime.UtcNow;
var exp = now.Add(_accessTtl);
var handler = new JwtSecurityTokenHandler();
var token = handler.CreateJwtSecurityToken(
issuer: _issuer, audience: _audience,
subject: new ClaimsIdentity(new Claim[]
{
new(JwtRegisteredClaimNames.Sub, userId),
new("tenant_id", tenantId),
new("session_id", sessionId),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
}.Concat(roles.Select(r => new Claim("role", r)))),
notBefore: now, expires: exp,
signingCredentials: _credentials);
var at = handler.WriteToken(token);
var rt = await IssueRefreshTokenAsync(userId, sessionId);
return (at, rt, exp);
}
private async Task<string> IssueRefreshTokenAsync(string userId, string sessionId)
{
var raw = Convert.ToBase64String(Rng.GetBytes(32));
var hash = ComputeHash(raw);
var token = $"{hash}.{userId}.{sessionId}";
await _rtStore.SaveAsync(hash, userId, sessionId, _refreshTtl);
return token;
}
public ClaimsPrincipal ValidateAccessToken(string accessToken)
{
var handler = new JwtSecurityTokenHandler();
return handler.ValidateToken(accessToken, new TokenValidationParameters
{
ValidIssuer = _issuer, ValidAudience = _audience,
IssuerSigningKey = _credentials.Key,
ValidateIssuer = true, ValidateAudience = true,
ValidateLifetime = true, ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.Zero
}, out _);
}
private static string ComputeHash(string raw) =>
Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(raw)));
}
JWT Best Practices
| Practice | Rationale |
|---|---|
| Use asymmetric signing (RS256/ES256) | Resource servers can validate without talking to auth server |
| Short access token lifetime (15 min) | Limit window of vulnerability if token is stolen |
| Always validate signature, exp, nbf, iss, aud | Prevent forged tokens and token reuse across services |
| Include jti (token ID) | Enable token blacklisting for immediate revocation |
| Never accept unsigned tokens | JWTs must always be signed; never use alg=none |
| Rotate JWKS keys periodically | Limit impact of key compromise; rotate every 90 days |
| Keep JWT payload minimal | Large headers degrade performance; keep under 8KB |
9. Token Rotation & Revocation
Token rotation is a critical security practice. Refresh token rotation ensures that every time a refresh token is used, a new refresh token is returned and the old one is invalidated. This limits the window of vulnerability: if an attacker steals a refresh token, using it will fail (because the legitimate user already rotated it) and trigger a security alert. This automatic theft detection is the primary reason to implement rotation.
C#
public class RefreshTokenManager
{
private readonly IDatabase _redis;
private readonly ILogger _logger;
public RefreshTokenManager(IConnectionMultiplexer redis, ILogger logger)
{ _redis = redis.GetDatabase(); _logger = logger; }
public async Task<string> RotateAsync(string oldRefreshToken, string userId, string sessionId, TimeSpan ttl)
{
var parts = oldRefreshToken.Split('.');
if (parts.Length != 3) throw new SecurityException("Invalid refresh token format");
var oldHash = parts[0];
var newRaw = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var newHash = ComputeHash(newRaw);
var newToken = $"{newHash}.{userId}.{sessionId}";
var oldKey = $"rt:{userId}:{oldHash}";
var newKey = $"rt:{userId}:{newHash}";
var tran = _redis.CreateTransaction();
var existed = tran.KeyDeleteAsync(oldKey);
_ = tran.StringSetAsync(newKey, sessionId, ttl);
var committed = await tran.ExecuteAsync();
if (!committed || !(await existed))
{
_logger.LogWarning("Token reuse detected for user {UserId}, session {SessionId}", userId, sessionId);
throw new SecurityException("Refresh token reuse detected " possible token theft");
}
return newToken;
}
public async Task RevokeAllAsync(string userId)
{
var server = _redis.Multiplexer.GetServer(...);
var keys = server.Keys(pattern: $"rt:{userId}:*");
foreach (var key in keys) await _redis.KeyDeleteAsync(key);
}
private static string ComputeHash(string input) =>
Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(input)));
}
Revocation Strategies
| Strategy | Latency | Storage Cost | Complexity |
|---|---|---|---|
| JTI Blacklist (Redis) | Real-time | Low (key per revoked token) | Low |
| Token Version Number | Next request | Low (one field per user) | Low |
| Short Expiration | 15 min max | None | None |
| Session ID in JWT | Real-time | Medium (session store) | Medium |
| Combined (All of above) | Real-time | Medium | Medium |
10. Session Management
Session management ties together authentication, token issuance, and user activity. Each session represents a user's authenticated presence on a device. Sessions contain device info, IP, creation time, last activity, and associated tokens. The lifecycle: create on login, maintain via refresh token usage, expire on logout or timeout. A session cleanup background service runs every 5 minutes, evicting idle sessions (30 min for web, 7 days for mobile).
C#
public class SessionManager
{
private readonly IDatabase _redis;
public SessionManager(IConnectionMultiplexer redis) { _redis = redis.GetDatabase(); }
public async Task<Session> CreateAsync(string userId, string tenantId, string device, string ip)
{
var sessionId = Guid.NewGuid().ToString();
var session = new Session(sessionId, userId, tenantId, device, ip, DateTime.UtcNow, DateTime.UtcNow, true);
var json = JsonSerializer.Serialize(session);
await _redis.StringSetAsync($"session:{sessionId}", json, TimeSpan.FromDays(30));
await _redis.SetAddAsync($"user_sessions:{userId}", sessionId);
return session;
}
public async Task<Session?> GetAsync(string sessionId)
{
var json = await _redis.StringGetAsync($"session:{sessionId}");
return json.HasValue ? JsonSerializer.Deserialize<Session>(json!) : null;
}
public async Task TouchAsync(string sessionId)
{
var json = await _redis.StringGetAsync($"session:{sessionId}");
if (!json.HasValue) return;
var session = JsonSerializer.Deserialize<Session>(json!)!;
session = session with { LastActivity = DateTime.UtcNow };
await _redis.StringSetAsync($"session:{sessionId}", JsonSerializer.Serialize(session), TimeSpan.FromDays(30));
}
public async Task RevokeAsync(string sessionId)
{
var json = await _redis.StringGetAsync($"session:{sessionId}");
if (!json.HasValue) return;
var session = JsonSerializer.Deserialize<Session>(json!)!;
await _redis.KeyDeleteAsync($"session:{sessionId}");
await _redis.SetRemoveAsync($"user_sessions:{session.UserId}", sessionId);
}
public async Task RevokeAllAsync(string userId)
{
var sessionIds = await _redis.SetMembersAsync($"user_sessions:{userId}");
foreach (var sid in sessionIds) await _redis.KeyDeleteAsync($"session:{sid}");
await _redis.KeyDeleteAsync($"user_sessions:{userId}");
}
}
public record Session(string SessionId, string UserId, string TenantId,
string DeviceInfo, string Ip, DateTime CreatedAt, DateTime LastActivity, bool IsActive);
11. Multi-Factor Authentication " TOTP & WebAuthn
MFA adds a second layer of security beyond passwords. Our system supports TOTP (time-based one-time passwords via authenticator apps), WebAuthn/FIDO2 (hardware keys and platform authenticators like Face ID, Touch ID, Windows Hello), and backup codes (10 single-use codes for recovery).
TOTP Implementation
C#
public class TotpService
{
private const int CodeLen = 6;
private const int Period = 30;
public (string secret, string url) GenerateSecret(string email, string issuer)
{
var secret = RandomNumberGenerator.GetBytes(20);
var b32 = Base32Encoding.ToString(secret);
var url = $"otpauth://totp/{Uri.EscapeDataString(issuer)}:{Uri.EscapeDataString(email)}" +
$"?secret={b32}&issuer={Uri.EscapeDataString(issuer)}&algorithm=SHA1&digits=6&period=30";
return (b32, url);
}
public bool Validate(string secret, string code)
{
var secretBytes = Base32Encoding.ToBytes(secret);
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var window = now / Period;
for (long i = window - 1; i <= window + 1; i++)
if (ComputeTotp(secretBytes, i) == code) return true;
return false;
}
private string ComputeTotp(byte[] secret, long window)
{
var time = BitConverter.GetBytes(window);
if (BitConverter.IsLittleEndian) Array.Reverse(time);
using var hmac = new HMACSHA1(secret);
var hash = hmac.ComputeHash(time);
var offset = hash[hash.Length - 1] & 0x0F;
var binary = (hash[offset] & 0x7F) << 24 | (hash[offset+1] & 0xFF) << 16 |
(hash[offset+2] & 0xFF) << 8 | (hash[offset+3] & 0xFF);
var otp = binary % (int)Math.Pow(10, CodeLen);
return otp.ToString(new string('0', CodeLen));
}
}
WebAuthn
WebAuthn (W3C standard) uses public-key cryptography for passwordless authentication. The device generates a key pair; the private key stays on the device (secured by TPM, Secure Enclave, biometrics); the public key is registered with the server. Authentication uses a cryptographic challenge-response: the server sends a challenge, the device signs it with the private key, and the server verifies with the public key. WebAuthn is phishing-resistant " the challenge is bound to the origin URL, so a fake site cannot relay the challenge to the real authenticator.
| Method | Phishing Resistant | UX | Cost | Recovery |
|---|---|---|---|---|
| TOTP | Medium | Good (6-digit code) | Free | Backup codes |
| SMS/Email | Low | Poor (wait for SMS) | $0.01/SMS | Fallback channel |
| WebAuthn Hardware | High | Good (tap key) | $25-$70 | Multiple keys |
| WebAuthn Platform | High | Excellent (Face ID / Touch ID) | Free (built-in) | iCloud Keychain sync |
12. Password Hashing " Argon2id & bcrypt
Password hashing is the most critical security control for credential auth. The hash function must be computationally expensive, memory-hard (GPU/ASIC resistant), and salted per user. Argon2id is the winner of the Password Hashing Competition (PHC) and the recommended algorithm. bcrypt is acceptable for legacy systems.
C#
public class PasswordHasher
{
private const int TimeCost = 4;
private const int MemoryCost = 65536; // 64 MB in KiB
private const int Parallelism = 8;
private const int SaltBytes = 16;
private const int HashBytes = 32;
public string Hash(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
var hash = Argon2id.HashPassword(
Encoding.UTF8.GetBytes(password), salt,
new Argon2idConfig
{
TimeCost = TimeCost, MemoryCost = MemoryCost,
Parallelism = Parallelism, HashLength = HashBytes
});
var salt64 = Convert.ToBase64String(salt);
var hash64 = Convert.ToBase64String(hash);
return $"$argon2id$v=19$m={MemoryCost},t={TimeCost},p={Parallelism}${salt64}${hash64}";
}
public bool Verify(string password, string hashedPassword)
{
var parts = hashedPassword.Split('$');
if (parts.Length != 6 || parts[1] != "argon2id")
throw new FormatException("Invalid hash format");
var memory = int.Parse(parts[3].Split(',')[0].Split('=')[1]);
var time = int.Parse(parts[3].Split(',')[1].Split('=')[1]);
var parallelism = int.Parse(parts[3].Split(',')[2].Split('=')[1]);
var salt = Convert.FromBase64String(parts[4]);
var expected = Convert.FromBase64String(parts[5]);
var computed = Argon2id.HashPassword(
Encoding.UTF8.GetBytes(password), salt,
new Argon2idConfig
{
TimeCost = time, MemoryCost = memory,
Parallelism = parallelism, HashLength = expected.Length
});
return CryptographicOperations.FixedTimeEquals(expected, computed);
}
}
Algorithm Comparison
| Algorithm | Memory-Hard | GPU/ASIC Resistant | Recommended For |
|---|---|---|---|
| Argon2id | Yes | Excellent | New systems |
| bcrypt | Minimal | Moderate | Legacy systems |
| PBKDF2 | No | Poor | Backward compat only |
| scrypt | Yes | Good | Alternative to Argon2 |
| SHA-256 (plain) | No | Trivial to crack | Never use |
13. Account Lockout & Brute Force Protection
Account lockout prevents brute-force attacks by temporarily disabling accounts after a threshold of failed attempts. We track failed attempts per user and per IP to mitigate both targeted and distributed attacks. Progressive delays and CAPTCHA are added after multiple failures.
C#
public class LockoutService
{
private readonly IDatabase _redis;
private const int MaxPerUser = 5;
private const int MaxPerIp = 20;
private const int Window = 15; // minutes
private const int LockoutDuration = 30; // minutes
public LockoutService(IConnectionMultiplexer redis) { _redis = redis.GetDatabase(); }
public async Task<LockoutResult> CheckAsync(string userId, string ip)
{
var userFails = (int)await _redis.StringGetAsync($"fail:{userId}");
var ipFails = (int)await _redis.StringGetAsync($"fail:ip:{ip}");
var remainingUser = Math.Max(0, MaxPerUser - userFails);
var remainingIp = Math.Max(0, MaxPerIp - ipFails);
if (userFails >= MaxPerUser) return LockoutResult.Locked($"Account locked for {LockoutDuration} min");
if (ipFails >= MaxPerIp) return LockoutResult.Locked($"IP blocked for {LockoutDuration} min");
return LockoutResult.Allowed(remainingUser, remainingIp);
}
public async Task RecordFailureAsync(string userId, string ip)
{
var expiry = TimeSpan.FromMinutes(Window);
await _redis.StringIncrementAsync($"fail:{userId}");
await _redis.KeyExpireAsync($"fail:{userId}", expiry);
await _redis.StringIncrementAsync($"fail:ip:{ip}");
await _redis.KeyExpireAsync($"fail:ip:{ip}", expiry);
}
public async Task ResetAsync(string userId, string ip)
{
await _redis.KeyDeleteAsync($"fail:{userId}");
await _redis.KeyDeleteAsync($"fail:ip:{ip}");
}
}
public class LockoutResult
{
public bool IsLocked { get; set; }
public string Message { get; set; }
public int RemainingAttempts { get; set; }
public static LockoutResult Allowed(int user, int ip) =>
new() { IsLocked = false, RemainingAttempts = Math.Min(user, ip) };
public static LockoutResult Locked(string msg) =>
new() { IsLocked = true, Message = msg };
}
Additional Protections
- CAPTCHA: After 3 failed attempts, require CAPTCHA to distinguish humans from bots.
- Rate Limiting: 10 login requests/second/IP at the API gateway.
- Anomaly Detection: Flag impossible travel (NYC to Tokyo in under an hour), new devices, unusual locations.
- Credential Stuffing: Check against Have I Been Pwned API during registration and password change.
- Progressive Delay: After 3 failures, add 2s → 4s → 8s artificial delays before responding.
14. RBAC & ABAC " Authorization Models
Authorization decides whether an authenticated user can access a resource. Role-Based Access Control (RBAC) assigns users to roles, and roles have permissions. Attribute-Based Access Control (ABAC) evaluates policies against user, resource, action, and environmental attributes. RBAC is simpler; ABAC is necessary for fine-grained, context-aware authorization.
RBAC Implementation
C#
public class RbacService
{
private readonly IRoleRepository _repo;
private readonly ICache _cache;
public async Task<bool> IsAuthorizedAsync(string userId, string tenantId, string permission, string? resourceId = null)
{
var roles = await GetRolesAsync(userId, tenantId);
foreach (var role in roles)
{
bool hasPermission = role.Permissions.Contains(permission) || role.Permissions.Contains("*");
bool hasResourceAccess = resourceId == null || resourceId.StartsWith(role.ResourcePattern);
if (hasPermission && hasResourceAccess) return true;
}
return false;
}
private async Task<List<Role>> GetRolesAsync(string userId, string tenantId)
{
var key = $"rbac:{tenantId}:{userId}";
var cached = await _cache.GetAsync<List<Role>>(key);
if (cached != null) return cached;
var roles = await _repo.GetUserRolesAsync(userId, tenantId);
var all = new List<Role>(roles);
// Resolve role inheritance
foreach (var r in roles.Where(r => !string.IsNullOrEmpty(r.ParentId)))
all.AddRange(await GetInheritedRolesAsync(r.ParentId));
return await _cache.SetAsync(key, all.Distinct().ToList(), TimeSpan.FromMinutes(15));
}
}
public record Role(string Id, string Name, string? ParentId, string ResourcePattern, List<string> Permissions);
ABAC with Policy Engine
ABAC uses policies that evaluate the full context of each request: user attributes, resource attributes, action type, and environment (time, location, device, risk score). We use a policy engine inspired by OPA (Open Policy Agent) with rule-based policies stored in the database.
| Model | Granularity | Complexity | Best For |
|---|---|---|---|
| RBAC | Coarse (role) | Low | Most B2B SaaS |
| Hierarchical RBAC | Coarse, inherited | Medium | Org hierarchies, projects |
| ABAC | Fine-grained | High | Healthcare, finance |
| ReBAC | Resource-specific | High | GitHub, Figma, Drive |
| Policy (OPA/Cedar) | Very fine | Very high | Multi-region, compliance |
15. API Key Management
API keys provide non-user authentication for programs, scripts, and integrations. Keys are long-lived credentials that must be scoped to minimal permissions, revocable, and never stored in plaintext. Our system: generates a key on enrollment, shows it once, stores only the hash (SHA-256) and an encrypted copy for admin recovery.
C#
public class ApiKeyService
{
private readonly IApiKeyRepository _repo;
private readonly IEncryption _encryptor;
public ApiKeyService(IApiKeyRepository repo, IEncryption encryptor)
{ _repo = repo; _encryptor = encryptor; }
public ApiKeyCreated Generate(string userId, string tenantId, string label, string[] scopes, TimeSpan? expiresIn = null)
{
var rawKey = $"{Guid.NewGuid():N}{Guid.NewGuid():N}";
var hash = HashKey(rawKey);
var encrypted = _encryptor.Encrypt(rawKey);
var key = new ApiKey
{
Id = Guid.NewGuid(), UserId = userId, TenantId = tenantId,
KeyHash = hash, EncryptedKey = encrypted,
Label = label, Scopes = scopes, IsActive = true,
CreatedAt = DateTime.UtcNow, ExpiresAt = expiresIn.HasValue ? DateTime.UtcNow.Add(expiresIn.Value) : null
};
_repo.SaveAsync(key);
return new ApiKeyCreated(key.Id, rawKey, rawKey[..8], key.CreatedAt);
}
public bool Validate(string rawKey, string requiredScope)
{
var hash = HashKey(rawKey);
var key = _repo.GetByHash(hash);
if (key == null || !key.IsActive) return false;
if (key.ExpiresAt.HasValue && DateTime.UtcNow > key.ExpiresAt.Value) return false;
if (!key.Scopes.Contains(requiredScope) && !key.Scopes.Contains("*")) return false;
return true;
}
private static string HashKey(string raw) =>
Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(raw)));
}
public record ApiKey(Guid Id, string UserId, string TenantId, string KeyHash, string EncryptedKey,
string Label, string[] Scopes, bool IsActive, DateTime CreatedAt, DateTime? ExpiresAt);
16. Rate Limiting & Throttling
Rate limiting protects the auth system from abuse: brute-force password guessing, DDoS on endpoints, and API key misuse. We implement a three-tier scheme: per user/API key, per IP, and global. The sliding window algorithm using Redis sorted sets provides precise rate limiting without the boundary problems of fixed windows.
C#
public class SlidingWindowRateLimiter
{
private readonly IDatabase _redis;
public SlidingWindowRateLimiter(IConnectionMultiplexer redis) { _redis = redis.GetDatabase(); }
public async Task<RateLimitResult> CheckAsync(string key, int limit, TimeSpan window)
{
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var windowStart = now - (long)window.TotalMilliseconds;
// Redis sorted set: score = timestamp, member = unique request ID
var script = @"
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
local count = redis.call('ZCARD', KEYS[1])
if count < tonumber(ARGV[2]) then
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[4])
redis.call('EXPIRE', KEYS[1], ARGV[5])
return {0, count + 1, ARGV[5]}
else
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
return {1, count, math.ceil(oldest[2] - ARGV[1])}
end
";
var requestId = Guid.NewGuid().ToString();
var result = (int[])await _redis.ScriptEvaluateAsync(script,
new RedisKey[] { $"rl:{key}" },
new RedisValue[] { windowStart.ToString(), limit.ToString(), now.ToString(), requestId, ((int)window.TotalSeconds).ToString() });
return new RateLimitResult(result[0] == 0, result[1], now + result[2]);
}
public async Task<bool> CheckLoginRateAsync(string userId, string ip)
{
// Per-user: 10 requests/minute
// Per-IP: 100 requests/minute
var userOk = (await CheckAsync($"login:user:{userId}", 10, TimeSpan.FromMinutes(1))).IsAllowed;
var ipOk = (await CheckAsync($"login:ip:{ip}", 100, TimeSpan.FromMinutes(1))).IsAllowed;
return userOk && ipOk;
}
}
public record RateLimitResult(bool IsAllowed, int CurrentCount, long ResetsAtMs);
17. Audit Logging & Observability
Audit logging is essential for security, compliance (SOC 2, GDPR), and debugging. Every authentication event, token issuance, role change, MFA enrollment, and admin action must be logged in an append-only, immutable audit trail. Logs are written to a dedicated audit database and streamed to an enterprise SIEM.
C#
public class AuditLogger
{
private readonly IDatabase _redis;
private readonly ILogger _logger;
private static readonly ConcurrentQueue<AuditEvent> _buffer = new();
public AuditLogger(IConnectionMultiplexer redis, ILogger logger)
{ _redis = redis.GetDatabase(); _logger = logger; }
public void LogEvent(string userId, string tenantId, string eventType,
string description, string? ip = null, Dictionary<string, string>? metadata = null)
{
var evt = new AuditEvent
{
Id = Guid.NewGuid(), Timestamp = DateTime.UtcNow,
UserId = userId, TenantId = tenantId, EventType = eventType,
Description = description, Ip = ip, Metadata = metadata
};
_buffer.Enqueue(evt);
if (_buffer.Count >= 100)
{
var batch = new List<AuditEvent>();
while (_buffer.TryDequeue(out var e)) batch.Add(e);
FlushBatchAsync(batch);
}
}
private async Task FlushBatchAsync(List<AuditEvent> events)
{
foreach (var e in events)
{
var key = $"audit:{e.TenantId}:{e.Timestamp:yyyyMMdd}";
await _redis.StreamAddAsync(key, new[]
{
new NameValueEntry("id", e.Id.ToString()),
new NameValueEntry("ts", e.Timestamp.ToString("O")),
new NameValueEntry("user", e.UserId),
new NameValueEntry("type", e.EventType),
new NameValueEntry("desc", e.Description),
new NameValueEntry("ip", e.Ip ?? "")
});
}
_logger.LogInformation("Flushed {Count} audit events", events.Count);
}
}
public record AuditEvent
{
public Guid Id { get; init; }
public DateTime Timestamp { get; init; }
public string UserId { get; init; }
public string TenantId { get; init; }
public string EventType { get; init; }
public string Description { get; init; }
public string? Ip { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
Audit Event Types
| Category | Events | Retention |
|---|---|---|
| Authentication | login_success, login_failure, logout, mfa_challenge, password_change | 7 years |
| Token | token_issued, token_refreshed, token_revoked, token_reuse_detected | 1 year |
| Admin | user_created, user_deleted, role_assigned, role_removed, permission_changed | 7 years |
| MFA | mfa_enrolled, mfa_disabled, backup_codes_generated, webauthn_key_registered | 1 year |
| Security | ip_blocked, rate_limit_exceeded, suspicious_login, impossible_travel | 1 year |
| Compliance | data_export, data_deletion, consent_granted, consent_revoked | 7 years |
19. Multi-Tenant SSO Architecture
Multi-tenant SSO allows each customer (tenant) to configure their own identity provider. Tenant A might use Azure AD with SAML, Tenant B uses Okta with OIDC, and Tenant C uses Google Workspace. The auth system must isolate tenant configurations, route authentication requests to the correct IdP, and manage tenant-specific signing certificates, metadata, and user provisioning.
C#
public class MultiTenantSsoService
{
private readonly ITenantRepository _tenants;
private readonly Dictionary<string, ISamlService> _samlServices = new();
private readonly Dictionary<string, IOidcService> _oidcServices = new();
public async Task<TenantConfig> GetTenantConfigAsync(string tenantId)
{
var tenant = await _tenants.GetByIdAsync(tenantId);
if (tenant.IdentityProvider.Type == "saml" && !_samlServices.ContainsKey(tenantId))
{
_samlServices[tenantId] = new SamlService(tenant.IdentityProvider.MetadataUrl, tenant.IdentityProvider.Certificate);
}
else if (tenant.IdentityProvider.Type == "oidc" && !_oidcServices.ContainsKey(tenantId))
{
_oidcServices[tenantId] = new OidcService(tenant.IdentityProvider.DiscoveryUrl, tenant.IdentityProvider.ClientId, tenant.IdentityProvider.ClientSecret);
}
return tenant;
}
public async Task<AuthResult> HandleSsoLoginAsync(string tenantId, string protocol, string response)
{
var tenant = await GetTenantConfigAsync(tenantId);
if (protocol == "saml")
{
var (nameId, attrs) = _samlServices[tenantId].ValidateResponse(response);
var user = await FindOrProvisionUser(tenantId, nameId, attrs);
return AuthResult.Success(user);
}
else if (protocol == "oidc")
{
var (idToken, userInfo) = await _oidcServices[tenantId].HandleCallbackAsync(response);
var claims = await _oidcServices[tenantId].ValidateIdTokenAsync(idToken);
var user = await FindOrProvisionUser(tenantId, claims.FindFirst("sub").Value, new Dictionary<string, string>());
return AuthResult.Success(user);
}
throw new NotSupportedException($"Protocol {protocol} not configured for tenant {tenantId}");
}
}
public record TenantConfig(string Id, string Name, IdentityProviderConfig IdentityProvider);
public record IdentityProviderConfig(string Type, string MetadataUrl, string Issuer,
string Certificate, string DiscoveryUrl, string ClientId, string ClientSecret);
20. SCIM Provisioning " User Sync
System for Cross-domain Identity Management (SCIM, RFC 7643/7644) is a RESTful protocol for automating user and group provisioning between an IdP and a service provider. Enterprises use SCIM to automatically create, update, and deactivate user accounts when changes occur in their HR system or directory. When an employee leaves the company, SCIM deactivates their account in all SaaS applications within minutes.
C#
public class ScimController : ControllerBase
{
private readonly IScimService _scim;
private readonly IUserRepository _users;
[HttpPost("/scim/v2/Users")]
[ProducesResponseType(typeof(ScimUser), 201)]
public async Task<IActionResult> CreateUser([FromBody] ScimCreateUserRequest request)
{
var user = new User
{
Id = Guid.NewGuid(), ExternalId = request.ExternalId ?? Guid.NewGuid().ToString(),
Email = request.Emails?.FirstOrDefault()?.Value,
FirstName = request.Name?.GivenName, LastName = request.Name?.FamilyName,
IsActive = request.Active
};
user = await _users.CreateAsync(user);
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, ToScimUser(user));
}
[HttpPatch("/scim/v2/Users/{id}")]
public async Task<IActionResult> PatchUser(Guid id, [FromBody] ScimPatchRequest request)
{
var user = await _users.GetByIdAsync(id);
foreach (var op in request.Operations)
{
if (op.Op == "replace" && op.Path == "active")
user.IsActive = (bool)op.Value;
else if (op.Op == "replace" && op.Path == "emails[type eq \"work\"].value")
user.Email = op.Value.ToString();
}
await _users.UpdateAsync(user);
return Ok(ToScimUser(user));
}
[HttpDelete("/scim/v2/Users/{id}")]
public async Task<IActionResult> DeactivateUser(Guid id)
{
var user = await _users.GetByIdAsync(id);
user.IsActive = false;
await _users.UpdateAsync(user);
return NoContent();
}
private ScimUser ToScimUser(User u) => new()
{
Schemas = new[] { "urn:ietf:params:scim:schemas:core:2.0:User" },
Id = u.Id.ToString(),
ExternalId = u.ExternalId,
Name = new ScimName { GivenName = u.FirstName, FamilyName = u.LastName },
Emails = new[] { new ScimEmail { Value = u.Email, Primary = true } },
Active = u.IsActive,
Meta = new ScimMeta { ResourceType = "User", Created = u.CreatedAt, LastModified = u.UpdatedAt }
};
}
public record ScimCreateUserRequest(string? ExternalId, ScimName? Name, ScimEmail[]? Emails, bool Active);
public record ScimPatchRequest(List<ScimPatchOperation> Operations);
public record ScimPatchOperation(string Op, string Path, object Value);
public record ScimUser { public string[] Schemas { get; init; } public string Id { get; init; } public string ExternalId { get; init; } public ScimName Name { get; init; } public ScimEmail[] Emails { get; init; } public bool Active { get; init; } public ScimMeta Meta { get; init; } }
public record ScimName(string GivenName, string FamilyName);
public record ScimEmail(string Value, bool Primary);
public record ScimMeta(string ResourceType, DateTime Created, DateTime LastModified);
21. Security " CSRF, XSS, Token Theft
The auth system itself must be secure against web application threats. We implement defenses against Cross-Site Request Forgery (CSRF), Cross-Site Scripting (XSS), token theft, and other attack vectors. The auth server is a high-value target " a compromise would grant attackers access to all connected applications.
CSRF Protection
In OAuth flows, the state parameter prevents CSRF on the authorization callback. The client generates a random state value before redirecting to the auth server, and validates it in the callback. Without this check, an attacker could trick a user into authorizing the attacker's app. The state parameter must be: (1) cryptographically random (minimum 128 bits), (2) bound to the user's session, (3) single-use, and (4) time-limited (5 minutes).
XSS Protection
The auth server renders user-controlled content (names, email addresses) on admin panels and user profile pages. All rendered content must be HTML-encoded. The auth server sets Content-Security-Policy headers restricting script sources. Using the auth server as a Universal Login page means it must be especially hardened against XSS since any script injection could steal tokens.
Token Theft Mitigation
| Attack Vector | Mitigation |
|---|---|
| Access token in URL | Use authorization code flow (code, never token, in URL) |
| Access token in localStorage | Store in memory only; if needed for persistence, use httpOnly cookies |
| Refresh token theft | Rotation: each use invalidates old token; reuse = alert |
| Authorization code interception | PKCE: attacker can't exchange code without verifier |
| Man-in-the-middle | HTTPS with HSTS; pin certificates for critical endpoints |
| Clickjacking | X-Frame-Options: DENY on auth pages |
| Session fixation | Generate new session ID after login |
| Log injection | Sanitize user input before writing to audit logs |
22. Compliance " SOC 2, GDPR, FedRAMP
Enterprise auth systems must comply with regulatory frameworks: SOC 2 (service organization controls), GDPR (EU data protection), FedRAMP (US government cloud), HIPAA (healthcare), and PCI DSS (payment card industry). Compliance is not optional for enterprise customers " it is a prerequisite for doing business.
Data Protection
| Requirement | SOC 2 | GDPR | Implementation |
|---|---|---|---|
| Encryption at rest | AES-256 | Art. 32 | Encrypt PII columns in DB; encrypt entire DB with TDE |
| Encryption in transit | TLS 1.2+ | Art. 32 | HTTPS only; HSTS; mTLS for inter-service calls |
| Access control | CC6.1 | Art. 25 | RBAC for admin UI; separate admin roles for audit, user management |
| Audit logging | CC3.1 | Art. 30 | Immutable audit log; 1 year hot, 7 year archive |
| Data retention | CC3.2 | Art. 5(1)(e) | Automated data cleanup; configurable per tenant |
| Data deletion | CC6.2 | Art. 17 (Right to erasure) | Soft delete + 30 day grace + permanent wipe |
| Breach notification | CC6.4 | Art. 33 (72 hours) | Automated alerting pipeline; incident response plan |
| Data portability | Not required | Art. 20 | Export API (JSON/CSV) for user data |
| Consent management | Not required | Art. 7 | Consent records; opt-in/opt-out; revocation tracking |
23. Monitoring & Alerting
Monitoring the auth system requires tracking metrics for availability, performance, security, and business health. Every metric should have an alert threshold and a runbook. The monitoring stack uses Prometheus + Grafana for metrics, Loki for logs, and PagerDuty for alerting.
| Category | Metric | Warning | Critical |
|---|---|---|---|
| Availability | Auth endpoint uptime | < 99.99% in 5 min | < 99.9% in 1 min |
| Performance | P99 login latency | > 500ms | > 2s |
| Performance | P99 token validation | > 10ms | > 50ms |
| Security | Login failure rate | > 10% | > 30% |
| Security | Account lockouts | > 100/hour | > 1000/hour |
| Security | Token reuse events | > 1/hour | > 10/hour |
| Business | Active logins/minute | Drop > 50% | Drop > 90% |
| Business | New user registrations | Drop > 50% | Zero for 30 min |
| Infra | CPU on auth servers | > 70% | > 90% |
| Infra | Redis memory usage | > 70% | > 85% |
| Infra | DB connection pool | > 70% | > 90% |
24. API Design for Auth
The auth system exposes a RESTful API for token management, user management, client management, and tenant management. All endpoints use JWT bearer authentication for admin operations and OAuth 2.0 flows for application integration. The API follows OpenAPI 3.0 specifications with auto-generated client SDKs.
HTTP
# Authentication Endpoints
POST /api/v1/auth/login # Email/password login
POST /api/v1/auth/logout # End session (revoke refresh token)
POST /api/v1/auth/refresh # Refresh access token
POST /api/v1/auth/mfa/verify # Verify MFA code
POST /api/v1/auth/mfa/enroll # Enroll in MFA (TOTP/WebAuthn)
POST /api/v1/auth/password/reset # Request password reset
POST /api/v1/auth/password/change # Change password (authenticated)
# OAuth 2.0 / OIDC Endpoints
GET /api/v1/oauth/authorize # Authorization endpoint
POST /api/v1/oauth/token # Token endpoint
GET /api/v1/oauth/userinfo # UserInfo endpoint
POST /api/v1/oauth/revoke # Token revocation
GET /.well-known/openid-configuration # OIDC discovery
GET /.well-known/jwks.json # JWKS public keys
# User Management (Admin)
GET /api/v1/users # List users
POST /api/v1/users # Create user
GET /api/v1/users/{id} # Get user
PATCH /api/v1/users/{id} # Update user
DELETE /api/v1/users/{id} # Deactivate user
POST /api/v1/users/{id}/roles # Assign roles
DELETE /api/v1/users/{id}/roles/{role} # Remove role
# Client Management (OAuth apps)
GET /api/v1/clients # List OAuth clients
POST /api/v1/clients # Register OAuth client
GET /api/v1/clients/{id} # Get client details
PUT /api/v1/clients/{id} # Update client
DELETE /api/v1/clients/{id} # Delete client
POST /api/v1/clients/{id}/rotate-secret # Generate new client secret
# Tenant Management (SSO)
GET /api/v1/tenants/{id}/saml/metadata # SAML metadata URL
PUT /api/v1/tenants/{id}/saml/config # Update SAML config
PUT /api/v1/tenants/{id}/oidc/config # Update OIDC config
POST /api/v1/tenants/{id}/scim/test # Test SCIM connection
# API Keys
GET /api/v1/api-keys # List API keys
POST /api/v1/api-keys # Create API key
DELETE /api/v1/api-keys/{id} # Delete API key
Error Response Format
JSON
{
"error": "invalid_grant",
"error_description": "The authorization code has expired or was already used",
"error_uri": "https://docs.example.com/errors/invalid_grant",
"request_id": "req_abc123"
}
25. Testing Strategy
Testing an auth system is particularly challenging because security vulnerabilities have severe consequences and because the system interacts with external IdPs, SMS gateways, email services, and hardware security modules. The testing strategy uses a test pyramid with unit tests, integration tests, contract tests, security tests, and end-to-end tests.
Testing Pyramid
| Layer | Test Type | Coverage | Tools |
|---|---|---|---|
| Unit (60%) | Token generation, password hashing, rate limiting, JWT validation, TOTP generation, SAML parsing | Individual functions and classes | xUnit, NUnit, FakeItEasy, Moq |
| Integration (25%) | Database operations, Redis cache, OAuth flows, token exchange, MFA verification | Interactions between services and data stores | Testcontainers (PostgreSQL, Redis), WireMock |
| Contract (10%) | API responses match OpenAPI spec, SCIM responses match RFC, SAML responses valid XML | Schema compliance | Pact (consumer-driven contracts), SchemaSpy |
| Security (3%) | SQL injection, XSS, CSRF, JWT alg=none, token tampering, rate limit bypass | Vulnerability detection | OWASP ZAP, SonarQube, custom fuzzing |
| E2E (2%) | Full OAuth login flow, SAML SSO, SCIM provisioning, password reset, MFA enrollment | User-facing scenarios | Playwright, Cypress, Postman/Newman |
Security Test Cases
C#
public class SecurityTests
{
[Fact]
public void Jwt_WithAlgNone_ShouldBeRejected()
{
var token = "eyJhbGciOiJub25lIn0.eyJzdWIiOiIxMjM0NTY3ODkwIn0.";
var handler = new JwtSecurityTokenHandler();
Assert.Throws<SecurityTokenException>(() =>
handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true, RequireSignedTokens = true,
IssuerSigningKey = new SymmetricSecurityKey("test"u8)
}, out _));
}
[Fact]
public void RateLimiter_ExceedsLimit_ShouldBlock()
{
var limiter = new SlidingWindowRateLimiter(_redis);
var key = "test-user";
for (int i = 0; i < 10; i++)
{
var result = limiter.CheckAsync(key, 10, TimeSpan.FromMinutes(1)).Result;
if (i < 10) Assert.True(result.IsAllowed);
else Assert.False(result.IsAllowed);
}
}
}
26. Cost Estimation
Estimating the infrastructure cost for an auth system depends on user count, request volume, and compliance requirements. The following estimates assume a mid-tier deployment on AWS with 10 million users and 100K tenants, with SOC 2 compliance. Costs are monthly unless noted.
| Component | Specification | Monthly Cost |
|---|---|---|
| Auth Server (EC2 / ECS) | 6 instances (c6i.4xlarge), across 3 AZs | $3,600 |
| PostgreSQL (RDS) | db.r6g.4xlarge (3 nodes: primary + 2 replicas) | $5,200 |
| Redis (ElastiCache) | cache.r6g.4xlarge (3 nodes: cluster mode) | $2,400 |
| Load Balancer (ALB) | 3 ALBs (public + internal + admin) | $600 |
| CDN / DDoS (CloudFront + WAF + Shield) | 100 TB/month | $3,000 |
| KMS / HSM | Key management, HSM backing | $500 |
| S3 / Glacier | Audit logs (7-year retention) | $800 |
| Monitoring (CloudWatch, Prometheus, Grafana) | Metrics, logs, alarms | $1,200 |
| SIEM (Splunk / Datadog) | 100 GB/day ingest | $4,000 |
| Penetration Testing | Annual, external vendor | $3,300/mo ($40K/yr) |
| SMS / Email (MFA codes) | 1 million SMS + 10 million emails | $1,500 |
| Incident Response Retainer | 24/7 SOC | $8,000 |
| Total | $34,100/month |
For a startup with 100K users, costs drop to approximately $5,000-8,000/month. For a hyper-scale system with 100M+ users, costs scale to $100K-300K/month depending on compliance requirements. The largest cost drivers are SIEM/licensing, SMS/email delivery, and compliance audits.
27. Interview Q&A Deep Dive
This section covers the most common and challenging system design interview questions about authentication and authorization systems. Each question includes the expected depth of answer, key numbers to remember, and common pitfalls to avoid.
Q1: How would you design a system that handles 100K OAuth 2.0 token requests per second?
Answer: The token endpoint is the most performance-critical path. First, optimize the signing operation: use ES256 (ECDSA) instead of RS256 (RSA) " ECDSA signing is ~10x faster and produces smaller tokens. Second, avoid database reads on the hot path: validate client credentials from Redis cache (client_id → hashed_secret), with PostgreSQL as the source of truth. Third, use connection pooling and keep-alive for all upstream connections. Fourth, horizontally scale the auth server behind a load balancer " the token endpoint is stateless (it only needs the signing key and cache), so adding instances provides linear scaling. For 100K TPS, use 20 instances each handling 5K TPS, with a Redis cluster for cache and a PostgreSQL read replica for credential lookup. Benchmark: ES256 signing takes ~0.1ms; with network overhead, total request time should be < 5ms P99.
Q2: How would you implement Single Sign-Out (SLO) across multiple applications?
Answer: SLO is the hardest part of SSO. The problem: when a user logs out of App A, they should also be logged out of App B and App C (which are SSO-connected). For OIDC, implement RP-initiated logout using the OIDC session management spec: the auth server maintains a session registry. When the user logs out at the auth server (the OP), it redirects the user to each known RP's logout endpoint (front-channel) and simultaneously notifies RPs via the back-channel (HTTP POST with a logout token). For SAML, use the SingleLogout profile with either HTTP-Redirect (front-channel) or SOAP (back-channel). The practical reality: SLO is unreliable because Apps B and C may be offline. Instead, use short-lived sessions (15 minute access tokens, 1 hour session max) so that sessions expire quickly. This is called "soft logoff" and is the most common enterprise approach.
Q3: How do you handle the case where an authorization code is used twice?
Answer: The auth server must mark codes as single-use immediately upon first redemption. If the same code is used again, return an error ("invalid_grant: authorization code already used") and optionally revoke all tokens issued by that code. This is a critical OAuth security control " if an attacker intercepts an authorization code and uses it before the legitimate client, the attacker gets the tokens and the client gets the "already used" error. The client should detect this error and restart the authorization flow. PKCE mitigates this by requiring the code_verifier that only the legitimate client knows.
Q4: How would you design a passwordless authentication system?
Answer: Passwordless auth uses magic links (send a one-time link to email) or one-time codes (send a 6-digit code to email/phone) or WebAuthn (biometric/public key). For magic links: (1) User enters email, (2) Server generates a one-time token (JWT, 15 min TTL), (3) Server sends email with link containing token, (4) User clicks link, (5) Server validates token, creates session. For WebAuthn: (1) User registers a credential (public key + credential ID stored server-side), (2) On login, server sends a challenge, (3) User's device signs the challenge with the private key, (4) Server verifies the signature with the stored public key. WebAuthn is the most secure passwordless method " it is phishing-resistant and provides strong authentication without any shared secrets.
Q5: How do you handle the tradeoff between security and user experience in MFA?
Answer: Adaptive authentication (risk-based MFA) is the answer. Low-risk actions (viewing public data, logging in from a known device on the corporate network) require only a password. High-risk actions (admin operations, login from a new device in a new country) require step-up authentication (MFA). The risk score is computed from: device reputation (known vs unknown), location (geographic distance from last login), time (business hours vs 3 AM), behavior (typing speed, mouse movements, typical pages). This model provides strong security without friction for legitimate users. Implementation: send the risk score as a JWT claim; the API gateway checks the score and either allows, requires MFA, or blocks the request.
Q6: How would you handle credential stuffing attacks?
Answer: Credential stuffing (using leaked passwords from other sites) is the most common attack on auth systems. Mitigations: (1) Rate limit login attempts per IP (100/hour) and per user (10/hour after 3 failures). (2) CAPTCHA on repeated failures. (3) Check passwords against HIBP (Have I Been Pwned) API during registration and force password change if compromised. (4) Implement device fingerprinting " if a login attempt comes from a device that has never been seen for this user, require email verification. (5) Use bot detection (reCAPTCHA v3, fingerprint.js) to identify automated login attempts. (6) Notify users via email when their account is accessed from a new device or location.
Q7: Explain the difference between authentication and authorization in the context of microservices.
Answer: In microservices, authentication is centralized (API gateway validates the JWT, caches the JWKS response, and forwards the validated claims to downstream services). Authorization is decentralized " each service has its own policy engine that evaluates the claims against resource-specific rules. The API gateway should NOT make authorization decisions because it doesn't understand the resource semantics. For example, the gateway can verify that User A has a valid token (authentication), but it cannot know if User A can delete Document X (authorization). The document service performs that check. This pattern is called "auth at the edge, authz in the service."
Key Numbers to Remember
| Metric | Value |
|---|---|
| Access token lifetime | 15 minutes |
| Refresh token lifetime | 90 days (rotate each use) |
| Authorization code lifetime | 10 minutes (single use) |
| PKCE code verifier length | 64 bytes (random) |
| Argon2id time cost | ≥ 3 iterations |
| Argon2id memory cost | ≥ 64 MB |
| Account lockout threshold | 5 attempts / 15 minutes |
| Account lockout duration | 30 minutes |
| Session idle timeout (web) | 30 minutes |
| Session idle timeout (mobile) | 7 days |
| JWT signing algorithm | ES256 (ECDSA P-256) |
| JWKS rotation frequency | Every 90 days |
| P99 token validation latency | < 10ms (cached JWKS) |
| P99 login latency (no IdP) | < 100ms |
| P99 login latency (with SSO) | < 500ms |
| SAML assertion max age | 5 minutes |
| Auth server availability target | 99.99% |
| Monthly cost (mid-tier) | ~$34,100 |
Pre-Interview Checklist
- Understand OAuth 2.0 flows (auth code, PKCE, client credentials, device) and their security properties
- Know JWT structure (header, payload, signature) and validation (signature, exp, iss, aud)
- Distinguish authentication from authorization clearly
- Explain PKCE and why it prevents code interception
- Understand SAML vs OIDC tradeoffs
- Know password hashing (Argon2id > bcrypt > PBKDF2) and why memory-hard matters
- Design a multi-tenant SSO system with tenant isolation
- Explain refresh token rotation and token theft detection
- Design rate limiting for a login endpoint
- Understand WebAuthn and public-key authentication
- Know RBAC vs ABAC tradeoffs
- Discuss compliance (SOC 2, GDPR) and data protection requirements
- Understand cost drivers for auth infrastructure
18. Social Login " Google, GitHub, Microsoft
Social login allows users to authenticate using their existing accounts from Google, GitHub, Microsoft, Apple, or other providers. This improves conversion rates (fewer passwords to remember), reduces support burden (password resets), and provides verified identity data (email, name, avatar). The implementation uses OAuth 2.0 / OIDC with each provider, abstracted behind a common social login provider interface.