IAM 101: The Ultimate Guide to Identity & Access Management — Keycloak, Auth0, Clerk & More
Every application has two fundamental questions to answer:
- Who are you? (Authentication)
- What are you allowed to do? (Authorization)
Get these wrong, and nothing else matters. Your beautiful UI, your optimized database queries, your 100% test coverage — none of it means anything if someone can access data they shouldn't or impersonate another user.
Identity and Access Management (IAM) is the discipline of answering those two questions correctly, at scale, without making your users want to throw their laptop out the window.
This guide covers the full stack: concepts, protocols, token formats, open-source solutions, managed services, implementation patterns, and the security mistakes that keep getting repeated.
The IAM Trinity: Authentication, Authorization, Identity
These three terms get mixed up constantly. Let's nail them down.
┌─────────────────────────────────────────────────────────────┐
│ IAM │
│ │
│ ┌─────────────────┐ ┌──────────────────┐ ┌───────────┐ │
│ │ Authentication │ │ Authorization │ │ Identity │ │
│ │ │ │ │ │ │ │
│ │ "Who are you?" │ │ "What can you │ │ "What do │ │
│ │ │ │ do?" │ │ we know │ │
│ │ Verify the │ │ Enforce rules │ │ about │ │
│ │ user is who │ │ about what │ │ you?" │ │
│ │ they claim │ │ resources a user │ │ │ │
│ │ to be │ │ can access │ │ Profile, │ │
│ │ │ │ │ │ roles, │ │
│ │ Passwords, │ │ Roles, policies, │ │ org, │ │
│ │ MFA, SSO, │ │ permissions, │ │ metadata │ │
│ │ biometrics │ │ scopes │ │ │ │
│ └─────────────────┘ └──────────────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────┘
Authentication (AuthN) proves you are who you claim to be. Username/password, fingerprint, a magic link, a hardware key — these are all authentication mechanisms.
Authorization (AuthZ) determines what an authenticated user is allowed to do. Can they read this document? Can they delete this account? Can they access the admin panel?
Identity is the broader concept — the user's profile, attributes, group memberships, and metadata. It's the data that both authentication and authorization decisions draw from.
A complete IAM system handles all three.
Core Concepts
Before diving into specific tools and protocols, here's a glossary of the concepts you'll encounter everywhere in IAM.
SSO (Single Sign-On)
Log in once, access multiple applications. Instead of separate credentials for your company's email, project management tool, and HR system, SSO lets you authenticate once and be recognized across all of them.
User logs in to IdP (e.g., Keycloak)
│
├──→ App A: "Keycloak says you're Alice. Welcome."
├──→ App B: "Keycloak says you're Alice. Welcome."
└──→ App C: "Keycloak says you're Alice. Welcome."
MFA (Multi-Factor Authentication)
Combining two or more authentication factors:
| Factor | What It Is | Examples |
|---|---|---|
| Something you know | Knowledge | Password, PIN, security question |
| Something you have | Possession | Phone (TOTP app), hardware key (YubiKey), email access |
| Something you are | Biometric | Fingerprint, face recognition, iris scan |
Real MFA requires factors from different categories. Password + security question is NOT MFA (both are "something you know").
Access Control Models
RBAC (Role-Based Access Control): Users are assigned roles, roles have permissions.
User: Alice
└─ Role: Editor
├─ Permission: posts.create
├─ Permission: posts.edit
└─ Permission: posts.delete (own)
ABAC (Attribute-Based Access Control): Access decisions based on attributes of the user, resource, action, and environment.
Rule: Allow if
user.department == resource.department AND
user.clearance_level >= resource.classification AND
environment.time is within business_hours
PBAC (Policy-Based Access Control): Centralized policies written in a policy language (e.g., OPA/Rego, Cedar). ABAC is essentially a form of PBAC, but PBAC emphasizes the policy engine as a first-class component.
Key Protocols
| Protocol | Purpose | Common Use |
|---|---|---|
| OAuth 2.0 | Authorization (delegated access) | "Allow this app to access my Google Drive" |
| OpenID Connect (OIDC) | Authentication (identity layer on OAuth 2.0) | "Sign in with Google" |
| SAML 2.0 | SSO, mostly enterprise | Enterprise SSO with legacy IdPs |
| LDAP | Directory services | Querying Active Directory for user info |
| SCIM | User provisioning | Syncing users between IdP and apps |
OAuth 2.0 Deep Dive
OAuth 2.0 is the industry standard for authorization. It allows users to grant third-party applications limited access to their resources without sharing their credentials.
Key players in every OAuth flow:
- Resource Owner — the user who owns the data
- Client — the application requesting access
- Authorization Server — issues tokens (e.g., Keycloak, Auth0)
- Resource Server — the API hosting the protected resources
Grant Type 1: Authorization Code (with PKCE)
This is the gold standard for web and mobile apps. PKCE (Proof Key for Code Exchange) is now required for all public clients and recommended for all clients.
┌──────────┐ ┌────────────────────┐
│ Browser │ │ Auth Server │
│ (Client) │ │ (e.g., Keycloak) │
└─────┬─────┘ └─────────┬──────────┘
│ │
│ 1. Generate code_verifier (random string) │
│ Compute code_challenge = SHA256(verifier) │
│ │
│ 2. GET /authorize? │
│ response_type=code& │
│ client_id=my-app& │
│ redirect_uri=https://app.com/callback& │
│ scope=openid profile& │
│ code_challenge=abc123& │
│ code_challenge_method=S256 │
│─────────────────────────────────────────────►│
│ │
│ 3. User authenticates (login form) │
│◄─────────────────────────────────────────────│
│ User consents to scopes │
│─────────────────────────────────────────────►│
│ │
│ 4. Redirect to callback with auth code │
│◄──── 302 https://app.com/callback?code=xyz ─│
│ │
│ 5. POST /token │
│ grant_type=authorization_code& │
│ code=xyz& │
│ code_verifier=original_random_string& │
│ redirect_uri=https://app.com/callback │
│─────────────────────────────────────────────►│
│ │
│ 6. Returns access_token + refresh_token │
│◄─────────────────────────────────────────────│
│ │
│ 7. Use access_token to call APIs │
│─────────────── Bearer token ────────────────►│ Resource Server
│ │
PKCE prevents authorization code interception attacks. The code_verifier is generated client-side and never sent over the redirect — only its hash (code_challenge) is sent initially. The auth server verifies the verifier matches the challenge during the token exchange.
Grant Type 2: Client Credentials
For server-to-server communication where no user is involved. Your backend service authenticates itself.
┌──────────┐ ┌────────────────────┐
│ Backend │ │ Auth Server │
│ Service │ │ │
└─────┬─────┘ └─────────┬──────────┘
│ │
│ POST /token │
│ grant_type=client_credentials& │
│ client_id=my-service& │
│ client_secret=s3cret& │
│ scope=api.read │
│─────────────────────────────────────────────►│
│ │
│ Returns access_token (no refresh token) │
│◄─────────────────────────────────────────────│
No browser, no redirect, no user interaction. Just machine-to-machine auth.
Grant Type 3: Device Code
For devices with limited input (smart TVs, CLI tools, IoT). You've seen this — "Go to example.com/activate and enter code: ABCD-1234".
┌──────────┐ ┌────────────────────┐
│ Device │ │ Auth Server │
│ (Smart │ │ │
│ TV) │ │ │
└─────┬─────┘ └─────────┬──────────┘
│ 1. POST /device/code │
│ client_id=tv-app │
│─────────────────────────────────────────────►│
│ │
│ Returns: device_code, user_code, │
│ verification_uri │
│◄─────────────────────────────────────────────│
│ │
│ 2. Display: "Go to example.com/activate │
│ and enter code: ABCD-1234" │
│ │
│ 3. Poll POST /token │
│ grant_type=device_code& │
│ device_code=xxx │
│─────────────────────────────────────────────►│
│ (repeat until user completes auth) │
│ │
│ 4. Returns access_token │
│◄─────────────────────────────────────────────│
Meanwhile, the user opens the URL on their phone, enters the code, and authenticates normally.
OpenID Connect — Identity on Top of OAuth
OAuth 2.0 is about authorization — it tells an app what a user allowed, but doesn't actually tell the app who the user is. OpenID Connect (OIDC) adds an identity layer on top.
The key addition: an ID Token (a JWT) that contains user identity claims.
OAuth 2.0 gives you: access_token → "You can access these resources"
OIDC adds: id_token → "The user is Alice, email alice@example.com"
When you see "Sign in with Google" or "Sign in with GitHub," that's OIDC in action. The app gets an ID token with claims like:
{
"iss": "https://accounts.google.com",
"sub": "1234567890",
"email": "alice@example.com",
"name": "Alice Johnson",
"picture": "https://example.com/photo.jpg",
"iat": 1774800000,
"exp": 1774803600
}
OIDC vs SAML
Both solve SSO, but differently:
| Aspect | OIDC | SAML 2.0 |
|---|---|---|
| Token Format | JWT (JSON) | XML |
| Transport | REST / JSON | XML / SOAP |
| Best For | Modern web/mobile apps | Enterprise, legacy systems |
| Complexity | Lower | Higher |
| Mobile Friendly | Yes | Not really |
| Adoption Trend | Growing | Stable (legacy) |
If you're building something new, use OIDC. SAML is mainly relevant when integrating with enterprise systems that require it.
JWT Deep Dive
JSON Web Tokens are the currency of modern auth. They carry claims between parties and can be verified without hitting a database.
Structure
A JWT has three parts, base64url-encoded and separated by dots:
header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzc0ODAwMDAwLCJleHAiOjE3NzQ4MDM2MDB9.
<signature>
Decoded:
┌─────────────────────────────────────────────────────────────┐
│ HEADER │
│ { │
│ "alg": "RS256", // signing algorithm │
│ "typ": "JWT" // token type │
│ } │
├─────────────────────────────────────────────────────────────┤
│ PAYLOAD (Claims) │
│ { │
│ "sub": "1234567890", // subject (user ID) │
│ "name": "Alice", // custom claim │
│ "role": "admin", // custom claim │
│ "iat": 1774800000, // issued at │
│ "exp": 1774803600 // expires at (1 hour later) │
│ } │
├─────────────────────────────────────────────────────────────┤
│ SIGNATURE │
│ RS256( │
│ base64UrlEncode(header) + "." + base64UrlEncode(payload), │
│ privateKey │
│ ) │
└─────────────────────────────────────────────────────────────┘
Access Tokens vs Refresh Tokens
| Property | Access Token | Refresh Token |
|---|---|---|
| Purpose | Authorize API requests | Get new access tokens |
| Lifetime | Short (5-60 minutes) | Long (days to weeks) |
| Sent to | Resource servers (APIs) | Authorization server only |
| Storage | Memory (preferred) | HttpOnly cookie or secure storage |
| Revocable | Not easily (stateless) | Yes (stored server-side) |
The flow:
1. User logs in → receives access_token (15 min) + refresh_token (7 days)
2. App uses access_token for API calls
3. access_token expires
4. App sends refresh_token to auth server
5. Auth server validates refresh_token, issues new access_token
6. Repeat until refresh_token expires → user must log in again
JWT Security Considerations
Always use asymmetric signing (RS256/ES256) for production. HS256 (HMAC) uses a shared secret — if your API server is compromised, the attacker can forge tokens. With RS256, only the auth server has the private key.
Always validate JWTs properly. At minimum:
// Node.js example with jose library
import { jwtVerify } from 'jose';
async function validateToken(token) {
try {
const { payload } = await jwtVerify(
token,
publicKey, // JWKS public key from your IdP
{
issuer: 'https://auth.example.com', // verify issuer
audience: 'https://api.example.com', // verify audience
clockTolerance: 5, // 5 second clock skew tolerance
}
);
// Additional custom checks
if (!payload.sub) throw new Error('Missing subject claim');
if (payload.role && !['admin', 'user'].includes(payload.role)) {
throw new Error('Invalid role');
}
return payload;
} catch (err) {
throw new Error(`Token validation failed: ${err.message}`);
}
}
Never trust the payload without verifying the signature. The payload is just base64 — anyone can read and modify it. The signature is what proves it hasn't been tampered with.
Open Source IAM Solutions
Keycloak — The Heavyweight Champion
Keycloak is the most feature-complete open-source IAM solution. Originally built by Red Hat, it's now a CNCF incubating project.
Key Concepts:
┌────────────────────────────────────────────────────┐
│ Keycloak Server │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Realm: "my-company" │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌───────────┐ │ │
│ │ │ Client: │ │ Client: │ │ Client: │ │ │
│ │ │ web-app │ │ mobile │ │ admin-api │ │ │
│ │ │ (public) │ │ (public) │ │ (confid.) │ │ │
│ │ └──────────┘ └──────────┘ └───────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ Users │ │ │
│ │ │ Alice (admin, editor) │ │ │
│ │ │ Bob (viewer) │ │ │
│ │ │ Carol (editor) │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────┐ ┌──────────────────┐ │ │
│ │ │ Roles │ │ Identity │ │ │
│ │ │ admin │ │ Providers │ │ │
│ │ │ editor │ │ Google │ │ │
│ │ │ viewer │ │ GitHub │ │ │
│ │ └────────────────┘ │ SAML Corp IdP │ │ │
│ │ └──────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
- Realm — a tenant/namespace. Each realm has its own users, clients, roles, and settings. You might have a "production" realm and a "staging" realm.
- Client — an application that users can authenticate through. Public clients (SPAs, mobile) or confidential clients (backend services).
- Roles — realm-level or client-level roles assigned to users.
- Identity Providers — external authentication sources (Google, GitHub, corporate SAML).
Running Keycloak:
# Docker — quickstart
docker run -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest start-dev
Integrating with a Node.js backend:
// middleware/auth.js
import Keycloak from 'keycloak-connect';
import session from 'express-session';
const memoryStore = new session.MemoryStore();
const keycloak = new Keycloak({ store: memoryStore }, {
realm: 'my-company',
'auth-server-url': 'http://localhost:8080',
'ssl-required': 'external',
resource: 'my-api',
'confidential-port': 0,
'bearer-only': true, // API-only, no login page
});
// Protect routes
app.get('/api/admin', keycloak.protect('realm:admin'), (req, res) => {
res.json({ message: 'Admin access granted' });
});
app.get('/api/profile', keycloak.protect(), (req, res) => {
// Any authenticated user
const userId = req.kauth.grant.access_token.content.sub;
res.json({ userId });
});
Keycloak Pros:
- Incredibly feature-rich (SSO, MFA, social login, RBAC, user federation, admin console)
- Standards-compliant (OAuth 2.0, OIDC, SAML 2.0, LDAP)
- Active development, CNCF backing
- Customizable themes and authentication flows
- Supports multi-tenancy via realms
Keycloak Cons:
- Resource-heavy (Java/Quarkus — needs ~512MB RAM minimum, more realistically 1-2GB)
- Steep learning curve for advanced configuration
- Admin UI, while comprehensive, can be overwhelming
- Customization sometimes requires writing Java SPIs
Authelia — Lightweight Self-Hosted
Authelia is a lightweight authentication server designed for reverse proxy setups (Nginx, Traefik, HAProxy). It's not a full IdP like Keycloak — it's focused on protecting web applications behind a reverse proxy.
User ──► Traefik/Nginx ──► Authelia (auth check) ──► Protected App
│
├── Portal (login, 2FA)
├── Access control rules
└── Session management
Best for: self-hosters running services behind a reverse proxy who want a single login portal with 2FA. Not suitable as an OAuth/OIDC provider for custom applications (though OIDC support has been added in recent versions).
Authentik — Modern Keycloak Alternative
Authentik is a newer, Python-based identity provider that aims to be more user-friendly than Keycloak while offering similar features.
Why consider Authentik over Keycloak:
- More intuitive admin UI
- Built-in application proxy (like Authelia, but integrated)
- Flow-based authentication designer — visually design login flows
- Lighter resource footprint than Keycloak
- Blueprints for declarative configuration
Trade-offs:
- Smaller community and ecosystem
- Fewer enterprise integrations
- Less battle-tested at massive scale
Ory — The Modular Stack
Ory takes a microservices approach to identity, splitting IAM into focused components:
| Component | Purpose |
|---|---|
| Ory Kratos | Identity management (registration, login, profile, recovery) |
| Ory Hydra | OAuth 2.0 / OIDC server |
| Ory Keto | Authorization (Google Zanzibar-inspired, relationship-based) |
| Ory Oathkeeper | API gateway / identity-aware proxy |
┌─────────────┐
│ Ory Kratos │ ← "Who is this user? Register, login, verify."
└──────┬──────┘
│
┌──────────▼──────────┐
│ Ory Hydra │ ← "Issue OAuth tokens for this user."
└──────────┬──────────┘
│
┌────────────▼────────────┐
│ Ory Oathkeeper │ ← "Check token, enrich request, forward."
└────────────┬────────────┘
│
┌────────▼────────┐
│ Ory Keto │ ← "Can this user do this action on this resource?"
└─────────────────┘
Ory is ideal when you want fine-grained control and only need specific pieces of the IAM puzzle. It's also fully headless — you build your own login UI. This is a strength for teams that want full design control and a weakness for teams that want something working quickly.
Managed / SaaS IAM Solutions
Auth0
Auth0 (now part of Okta) is the most established managed IAM platform for developers.
What you get:
- Universal Login (hosted login page — recommended for security)
- 30+ social connections out of the box
- Rules, Actions, and Hooks for custom logic during auth flows
- Machine-to-machine auth
- RBAC and fine-grained authorization
- Breach detection, bot detection, MFA
- Extensive SDKs for every platform
// React integration with Auth0
import { Auth0Provider, useAuth0 } from '@auth0/auth0-react';
// Wrap your app
<Auth0Provider
domain="your-tenant.auth0.com"
clientId="YOUR_CLIENT_ID"
authorizationParams={{
redirect_uri: window.location.origin,
audience: 'https://api.example.com',
}}
>
<App />
</Auth0Provider>
// Use in components
function Profile() {
const { user, isAuthenticated, loginWithRedirect, logout } = useAuth0();
if (!isAuthenticated) {
return <button onClick={loginWithRedirect}>Log In</button>;
}
return (
<div>
<img src={user.picture} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
<button onClick={() => logout({ returnTo: window.location.origin })}>
Log Out
</button>
</div>
);
}
Pricing: Free up to 25,000 MAU (monthly active users) with limited features. Paid plans start at ~$35/month. Enterprise pricing can get expensive quickly.
Pros: Mature, well-documented, extensive features, good SDKs.
Cons: Pricing scales steeply, vendor lock-in, can be slow to adopt new standards, Okta acquisition has caused some churn.
Clerk
Clerk is the newer player focused entirely on developer experience. It's built for modern React/Next.js apps and it shows.
// Next.js App Router integration
// middleware.ts
import { clerkMiddleware } from '@clerk/nextjs/server';
export default clerkMiddleware();
// In components
import { SignIn, SignUp, UserButton, useUser } from '@clerk/nextjs';
function Header() {
const { isSignedIn, user } = useUser();
return (
<header>
{isSignedIn ? (
<>
<span>Hello, {user.firstName}</span>
<UserButton /> {/* Pre-built avatar dropdown with settings */}
</>
) : (
<SignIn /> {/* Pre-built sign-in component */}
)}
</header>
);
}
What makes Clerk stand out:
- Pre-built UI components (sign-in, sign-up, user profile, organization switcher)
- Multi-tenancy / organizations built in
- First-class Next.js, Remix, and Expo support
- Webhooks for syncing user data to your database
- Session management with short-lived JWTs
Pros: Best DX in the category, beautiful default UI, fast integration, good multi-tenant support.
Cons: Newer (less battle-tested), pricing can add up at scale, less flexibility for non-standard auth flows, primarily focused on React ecosystem.
Firebase Auth
Google's Firebase Auth is the "just make login work" option.
import { getAuth, signInWithPopup, GoogleAuthProvider } from 'firebase/auth';
const auth = getAuth();
const provider = new GoogleAuthProvider();
async function signInWithGoogle() {
const result = await signInWithPopup(auth, provider);
const user = result.user;
console.log(user.displayName, user.email, user.photoURL);
}
Pros: Dead simple, generous free tier (50K MAU), works across web/iOS/Android, integrates with Firebase ecosystem.
Cons: Limited authorization (no RBAC built in), no organization/multi-tenant support, customization is limited, you're locked into Firebase ecosystem.
AWS Cognito
AWS's IAM solution for applications. If you're all-in on AWS, Cognito integrates with API Gateway, ALB, AppSync, and other services.
Pros: AWS-native integration, pay-per-use pricing, supports SAML and OIDC federation, scales to millions of users.
Cons: One of the worst developer experiences in the category, confusing documentation (User Pools vs Identity Pools), limited customization of hosted UI, migration out is painful.
Supabase Auth
Supabase Auth is the open-source-backed option that pairs with Supabase's Postgres database.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// Sign up
const { data, error } = await supabase.auth.signUp({
email: 'alice@example.com',
password: 'securepassword',
});
// Sign in with OAuth
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
});
// Row Level Security — authorization at the database level
// In Supabase SQL editor:
// CREATE POLICY "Users can only see their own data"
// ON profiles FOR SELECT
// USING (auth.uid() = user_id);
What's unique: Row Level Security (RLS) in Postgres means authorization rules live in the database. Your API doesn't need authorization middleware — the database enforces it.
Pros: Open source (GoTrue), RLS for authorization, good DX, integrates with Supabase ecosystem.
Cons: Tightly coupled with Supabase, RLS policies can get complex, limited enterprise features.
Comparison At a Glance
| Solution | Type | Best For | Pricing (starting) | OIDC/OAuth | RBAC | Multi-Tenant |
|---|---|---|---|---|---|---|
| Keycloak | Open Source | Full control, enterprise | Free (self-hosted) | Yes | Yes | Yes (realms) |
| Authentik | Open Source | Modern self-hosted | Free (self-hosted) | Yes | Yes | Yes |
| Ory | Open Source | Modular, headless | Free (self-hosted) | Yes | Yes (Keto) | Manual |
| Auth0 | SaaS | Feature-rich managed | Free to 25K MAU | Yes | Yes | Yes |
| Clerk | SaaS | DX-focused, React | Free to 10K MAU | Yes | Yes | Yes |
| Firebase Auth | SaaS | Simple, mobile | Free to 50K MAU | Limited | No | No |
| Cognito | SaaS | AWS-native | Pay per MAU | Yes | Limited | Limited |
| Supabase Auth | Open Source + SaaS | Supabase stack | Free to 50K MAU | Limited | Via RLS | No |
RBAC vs ABAC — Implementation Patterns
RBAC Implementation
RBAC is the most common authorization model. Here's a practical implementation:
// Define roles and permissions
const PERMISSIONS = {
admin: ['users.read', 'users.write', 'users.delete', 'posts.read', 'posts.write', 'posts.delete', 'settings.manage'],
editor: ['posts.read', 'posts.write', 'posts.delete', 'users.read'],
viewer: ['posts.read', 'users.read'],
};
// Middleware
function requirePermission(permission) {
return (req, res, next) => {
const userRole = req.user.role; // from JWT or session
const userPermissions = PERMISSIONS[userRole] || [];
if (!userPermissions.includes(permission)) {
return res.status(403).json({
error: 'Forbidden',
message: `Role '${userRole}' does not have '${permission}' permission`,
});
}
next();
};
}
// Usage
app.get('/api/users', requirePermission('users.read'), getUsers);
app.delete('/api/users/:id', requirePermission('users.delete'), deleteUser);
app.put('/api/settings', requirePermission('settings.manage'), updateSettings);
Hierarchical RBAC — roles inherit permissions from lower roles:
const ROLE_HIERARCHY = {
admin: ['editor'],
editor: ['viewer'],
viewer: [],
};
function getAllPermissions(role) {
const direct = PERMISSIONS[role] || [];
const inherited = (ROLE_HIERARCHY[role] || [])
.flatMap(parentRole => getAllPermissions(parentRole));
return [...new Set([...direct, ...inherited])];
}
ABAC Implementation
ABAC is more flexible but more complex. Here's a pattern using policy functions:
// Policy definitions
const policies = {
'document.read': (user, resource, context) => {
// Public documents — anyone can read
if (resource.visibility === 'public') return true;
// Same department can read internal docs
if (resource.visibility === 'internal' &&
user.department === resource.department) return true;
// Confidential — only author or managers
if (resource.visibility === 'confidential' &&
(resource.authorId === user.id || user.role === 'manager')) return true;
return false;
},
'document.edit': (user, resource, context) => {
// Only author can edit
if (resource.authorId !== user.id) return false;
// Only during business hours (if policy requires)
if (resource.requiresBusinessHours) {
const hour = new Date().getHours();
if (hour < 9 || hour > 17) return false;
}
return true;
},
'document.delete': (user, resource, context) => {
// Only admins or the author (within 24 hours of creation)
if (user.role === 'admin') return true;
if (resource.authorId === user.id) {
const ageMs = Date.now() - new Date(resource.createdAt).getTime();
const twentyFourHours = 24 * 60 * 60 * 1000;
return ageMs < twentyFourHours;
}
return false;
},
};
// Middleware
function authorize(action) {
return async (req, res, next) => {
const policy = policies[action];
if (!policy) {
return res.status(500).json({ error: 'No policy defined for action' });
}
// Fetch the resource (you'd typically do this from DB)
const resource = await getResource(req);
const context = { ip: req.ip, time: new Date() };
if (!policy(req.user, resource, context)) {
return res.status(403).json({ error: 'Access denied by policy' });
}
req.resource = resource;
next();
};
}
// Usage
app.get('/api/documents/:id', authorize('document.read'), getDocument);
app.put('/api/documents/:id', authorize('document.edit'), updateDocument);
app.delete('/api/documents/:id', authorize('document.delete'), deleteDocument);
When to Use Which
| Factor | RBAC | ABAC |
|---|---|---|
| Complexity | Simple | Complex |
| Flexibility | Limited (predefined roles) | Very flexible (any attribute) |
| Performance | Fast (role lookup) | Slower (policy evaluation) |
| Audit Trail | Easy ("user has role X") | Harder ("policy evaluated to true because...") |
| Best For | Most apps, internal tools, B2B | Healthcare, finance, government, multi-tenant |
| Maintenance | Roles can explode in number | Policies can get complex |
In practice, most apps start with RBAC and add ABAC-style rules for specific edge cases. You don't need to choose one or the other — they compose well.
Open Source vs Managed — Decision Framework
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ Choose OPEN SOURCE (Keycloak, Authentik, Ory) when: │
│ │
│ ✓ Data sovereignty / compliance requires on-premise │
│ ✓ You need deep customization of auth flows │
│ ✓ You have ops/DevOps capacity to run and maintain it │
│ ✓ Long-term cost optimization (high user count) │
│ ✓ You need full control over the identity data │
│ ✓ Regulated industry (healthcare, finance, government) │
│ │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Choose MANAGED (Auth0, Clerk) when: │
│ │
│ ✓ Small team without dedicated security/ops engineers │
│ ✓ Need to ship auth fast (days, not weeks) │
│ ✓ Compliance handled by vendor (SOC 2, HIPAA BAA) │
│ ✓ Lower user count where pricing is manageable │
│ ✓ You don't want to manage infrastructure for auth │
│ ✓ Pre-built UI components are a significant time saver │
│ │
└─────────────────────────────────────────────────────────────────────┘
A rough cost comparison at scale:
| MAU | Auth0 (est.) | Clerk (est.) | Keycloak (self-hosted) |
|---|---|---|---|
| 1,000 | Free | Free | ~$50/mo (small VM) |
| 10,000 | Free | Free | ~$50/mo |
| 50,000 | ~$250/mo | ~$200/mo | ~$100/mo (larger VM) |
| 100,000 | ~$700/mo | ~$500/mo | ~$150/mo |
| 500,000 | ~$3,000+/mo | Custom | ~$300/mo (HA setup) |
Self-hosted is cheaper at scale but costs engineer time. At 500K MAU, the savings are significant — but you're also responsible for uptime, security patches, backups, and scaling.
Common Security Mistakes
1. Storing Tokens in localStorage
// DON'T DO THIS
localStorage.setItem('access_token', token);
localStorage is accessible to any JavaScript running on your page, including XSS payloads. If your site has a single XSS vulnerability, the attacker gets the token.
Better approach:
- Store access tokens in memory (JavaScript variable)
- Store refresh tokens in HttpOnly, Secure, SameSite cookies
- Use the BFF (Backend for Frontend) pattern for SPAs
2. Not Validating JWTs Properly
Common validation failures:
- Not checking the
exp(expiration) claim - Not verifying the
iss(issuer) — accepting tokens from any issuer - Not verifying the
aud(audience) — accepting tokens meant for other services - Using
alg: none— accepting unsigned tokens (the classic JWT attack) - Not validating the signature against the correct public key
3. Long-Lived Access Tokens
// Don't issue access tokens that last a week
const token = jwt.sign(payload, secret, { expiresIn: '7d' }); // Bad
// Short-lived access tokens + refresh token rotation
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' }); // Good
If an access token is stolen, the blast radius should be limited by its short lifetime.
4. No Refresh Token Rotation
Refresh tokens should be rotated on every use. When a refresh token is used to get a new access token, the old refresh token should be invalidated and a new one issued. This limits the window for stolen refresh token attacks.
Request: refresh_token=abc123
Response: access_token=new_at, refresh_token=def456 (abc123 is now invalid)
If someone tries to use the old abc123, that's a signal of token theft — invalidate the entire session.
5. Over-Scoping Permissions
Principle of least privilege: give users and services only the permissions they need. Don't make every service account an admin. Don't give your frontend client access to user management APIs.
6. Not Rate-Limiting Auth Endpoints
Login and token endpoints are prime targets for brute force and credential stuffing attacks. Always rate-limit:
import rateLimit from 'express-rate-limit';
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per window
message: 'Too many login attempts, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
app.post('/api/auth/login', authLimiter, loginHandler);
7. Insecure Password Reset Flows
- Reset tokens should be single-use and short-lived (15-30 minutes)
- Don't reveal whether an email exists ("If an account exists, we sent a reset email")
- Don't send the new password via email — send a link to set one
- Invalidate all sessions after a password reset
Real-World Architecture Patterns
Pattern 1: BFF (Backend for Frontend)
For SPAs, the BFF pattern keeps tokens on the server side:
┌──────────┐ cookie ┌────────────┐ access_token ┌──────────┐
│ Browser │ ◄────────────► │ BFF (Node) │ ◄───────────────► │ API │
│ (React) │ (session ID) │ │ │ Server │
└──────────┘ └──────┬─────┘ └──────────┘
│
┌─────▼──────┐
│ Auth │
│ Server │
│ (Keycloak) │
└────────────┘
The browser never sees the access token. The BFF handles the OAuth flow, stores tokens server-side, and proxies API requests with the token attached. The browser only gets a session cookie.
Pattern 2: API Gateway with Token Validation
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ Client │ ── Bearer ───► │ API Gateway │ ── valid ──► │ Microservice│
│ │ token │ (validates │ request │ (trusts │
│ │ │ JWT, checks │ │ gateway) │
│ │ │ rate limits)│ │ │
└──────────┘ └──────┬───────┘ └──────────────┘
│
┌─────▼──────┐
│ Auth │
│ Server │
│ (JWKS │
│ endpoint) │
└────────────┘
The gateway validates every token using the auth server's JWKS (JSON Web Key Set) endpoint. Microservices behind the gateway can trust requests that make it through, simplifying their auth logic.
Pattern 3: Machine-to-Machine with Client Credentials
┌──────────────┐ client_credentials ┌────────────────┐
│ Service A │ ──────────────────────────► │ Auth Server │
│ (Order Svc) │ ◄── access_token ──────── │ │
└───────┬──────┘ └────────────────┘
│
│ Bearer token
▼
┌──────────────┐
│ Service B │
│ (Inventory) │ ← validates token, checks scope "inventory.read"
└──────────────┘
Each service has its own client credentials. Service A requests a token scoped to what it needs from Service B. This is standard for microservice architectures.
Putting It All Together
IAM is not a feature you bolt on at the end. It's a foundational architectural decision that affects every layer of your stack. Here's a pragmatic approach:
Start with a managed solution if you're a small team. Clerk or Auth0 will get you to production in a day. You can always migrate later.
Choose Keycloak or Authentik if you need on-premise, have compliance requirements, or are at scale where managed pricing hurts.
Always use OIDC/OAuth 2.0 as your protocol. Don't invent custom auth schemes.
Implement RBAC first. It covers 90% of use cases. Add ABAC policies for specific requirements.
Separate authentication from authorization. Your IdP handles "who is this user." Your application (or a policy engine) handles "what can they do."
Use short-lived access tokens with refresh token rotation. This is non-negotiable for production systems.
Never trust the client. Always validate tokens server-side. Always check permissions server-side. The browser is hostile territory.
Audit everything. Log authentication events (login, logout, failed attempts, token refresh, password changes). You'll need this for security incidents and compliance.
If this guide helped you navigate the IAM landscape, let's connect! I write about backend architecture, security, and developer tools.
Top comments (0)