SCIM Provisioning, MFA, Risk-Based Auth & Enterprise Features
Enterprise-Grade SSO Extensions
Add automated provisioning, strong MFA, adaptive authentication, and observability
| Feature | Okta/Azure AD | Authentik (This Guide) |
|---|---|---|
| SCIM Provisioning | ✅ Built-in | ✅ Native support |
| FIDO2/WebAuthn | ✅ Built-in | ✅ Full support |
| Conditional Access | ✅ Advanced policies | ✅ Flow-based policies |
| LDAP/AD Sync | ✅ AD Connect | ✅ LDAP Source |
| Cost (1000 users) | $6,000-$18,000/yr | $0 (self-hosted) |
| Module | Focus | Time |
|---|---|---|
| SCIM Provisioning | Automated user lifecycle | 2 hours |
| MFA/FIDO2 | Hardware keys, TOTP, WebAuthn | 1.5 hours |
| Risk-Based Auth | Conditional access flows | 2 hours |
| LDAP Sync | Directory integration | 1.5 hours |
| Monitoring | Prometheus + Grafana | 1.5 hours |
| High Availability | Multi-node deployment | 2 hours |
Total: 10.5 hours (builds on Project A)
Automated User Lifecycle Management
Users created in Authentik automatically appear in connected applications
Navigate to Applications → Providers → Create
# ============================================
# SCIM PROVIDER SETTINGS
# ============================================
Name: "GitHub Enterprise SCIM"
Protocol: "SCIM"
# Target application URL (where users are provisioned)
URL: "https://api.github.com/scim/v2/organizations/YOUR_ORG"
# Bearer token for authentication
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Which users to provision
Filter Group: "github-users"
# Attribute Mapping
User Mapping:
- userName → user.username
- name.givenName → user.name
- name.familyName → user.attributes.last_name
- emails[0].value → user.email
- active → user.is_active
# ============================================
# COMMON SCIM ENDPOINTS
# ============================================
# GitHub Enterprise
https://api.github.com/scim/v2/organizations/{org}
# Slack Enterprise Grid
https://api.slack.com/scim/v2
# AWS SSO
https://scim.{region}.amazonaws.com/{tenant-id}/scim/v2
# Zoom
https://api.zoom.us/scim2
#!/usr/bin/env python3
"""
============================================
MOCK SCIM 2.0 SERVER
Test Authentik SCIM provisioning locally
============================================
"""
from flask import Flask, request, jsonify
import sqlite3
import uuid
from datetime import datetime
app = Flask(__name__)
# Initialize database
def init_db():
conn = sqlite3.connect('scim_users.db')
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
userName TEXT UNIQUE NOT NULL,
givenName TEXT,
familyName TEXT,
email TEXT,
active INTEGER DEFAULT 1,
created_at TEXT,
updated_at TEXT
)
''')
conn.commit()
conn.close()
# ============================================
# SCIM 2.0 ENDPOINTS
# ============================================
@app.route('/scim/v2/Users', methods=['POST'])
def create_user():
"""SCIM Create User - Called when user added to provisioned group"""
data = request.get_json()
user_id = str(uuid.uuid4())
conn = sqlite3.connect('scim_users.db')
cursor = conn.cursor()
try:
cursor.execute('''
INSERT INTO users (id, userName, givenName, familyName, email, active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
user_id,
data.get('userName'),
data.get('name', {}).get('givenName'),
data.get('name', {}).get('familyName'),
data.get('emails', [{}])[0].get('value'),
1 if data.get('active', True) else 0,
datetime.utcnow().isoformat(),
datetime.utcnow().isoformat()
))
conn.commit()
print(f"✅ SCIM CREATE: {data.get('userName')}")
return jsonify({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": user_id,
"userName": data.get('userName'),
"active": data.get('active', True),
"meta": {
"resourceType": "User",
"created": datetime.utcnow().isoformat(),
"location": f"/scim/v2/Users/{user_id}"
}
}), 201
except sqlite3.IntegrityError:
return jsonify({"error": "User already exists"}), 409
finally:
conn.close()
@app.route('/scim/v2/Users/<user_id>', methods=['PATCH'])
def update_user(user_id):
"""SCIM Update User - Called on attribute changes or deprovisioning"""
data = request.get_json()
conn = sqlite3.connect('scim_users.db')
cursor = conn.cursor()
# Process SCIM PATCH operations
for op in data.get('Operations', []):
if op.get('path') == 'active':
active = 1 if op.get('value') else 0
cursor.execute('UPDATE users SET active = ?, updated_at = ? WHERE id = ?',
(active, datetime.utcnow().isoformat(), user_id))
status = "ENABLED" if active else "DISABLED"
print(f"⚠️ SCIM UPDATE: User {user_id} - {status}")
conn.commit()
conn.close()
return jsonify({"id": user_id, "status": "updated"}), 200
@app.route('/scim/v2/Users/<user_id>', methods=['DELETE'])
def delete_user(user_id):
"""SCIM Delete User - Full deprovisioning"""
conn = sqlite3.connect('scim_users.db')
cursor = conn.cursor()
cursor.execute('DELETE FROM users WHERE id = ?', (user_id,))
conn.commit()
conn.close()
print(f"🗑️ SCIM DELETE: User {user_id}")
return '', 204
@app.route('/scim/v2/Users', methods=['GET'])
def list_users():
"""SCIM List Users - For reconciliation"""
conn = sqlite3.connect('scim_users.db')
cursor = conn.cursor()
cursor.execute('SELECT * FROM users')
users = cursor.fetchall()
conn.close()
return jsonify({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": len(users),
"Resources": [{
"id": u[0], "userName": u[1], "active": bool(u[5])
} for u in users]
})
if __name__ == '__main__':
init_db()
print("🚀 SCIM Mock Server: http://localhost:5005/scim/v2")
app.run(host='0.0.0.0', port=5005, debug=True)
This is exactly how Okta, Azure AD, and CyberArk Identity provision users to SaaS apps. SCIM is the industry standard.
Phishing-Resistant Authentication
FIDO2/WebAuthn provides the strongest MFA - no codes to intercept
Navigate to Flows & Stages → Stages → Create
# ============================================
# WEBAUTHN AUTHENTICATOR SETUP STAGE
# For enrolling security keys
# ============================================
Stage Type: "Authenticator WebAuthn Setup"
Name: "webauthn-setup"
# Supported authenticator types
User Verification: "preferred" # or "required" for higher security
Resident Key Requirement: "preferred"
# Device types to allow
Authenticator Attachment: "any" # platform, cross-platform, or any
# ============================================
# WEBAUTHN VALIDATION STAGE
# For validating during login
# ============================================
Stage Type: "Authenticator Validation"
Name: "mfa-validation"
Device Classes:
✅ WebAuthn Authenticators
✅ TOTP Authenticators
☐ Static Tokens (disable for security)
Not Configured Action: "Force user to configure"
# ============================================
# ADD TO AUTHENTICATION FLOW
# ============================================
# Go to Flows → default-authentication-flow → Stage Bindings
# Add the mfa-validation stage AFTER password verification
Order: 20 # After password stage (order 10)
Stage: mfa-validation
Evaluate on plan: ✅
Re-evaluate policies: ☐
# ============================================
# TOTP AUTHENTICATOR SETUP STAGE
# ============================================
Stage Type: "Authenticator TOTP Setup"
Name: "totp-setup"
# TOTP Configuration
Digits: 6 # Standard 6-digit codes
Algorithm: "SHA1" # Most compatible
Period: 30 # Seconds per code
# ============================================
# ADD MFA ENROLLMENT FLOW
# ============================================
# Create new flow: mfa-enrollment-flow
Flow Name: "MFA Enrollment"
Designation: "Stage Configuration"
# Stage Bindings (in order):
1. totp-setup (Order: 10)
2. webauthn-setup (Order: 20)
# Link to user settings
# Go to Flows → default-user-settings-flow
# Add link to mfa-enrollment-flow
# ============================================
# CREATE MFA REQUIREMENT POLICY
# Navigate to: Customization → Policies → Create
# ============================================
Policy Type: "Expression Policy"
Name: "require-mfa-for-admins"
# Expression (Python-like syntax)
Expression:
"""
# Require MFA for users in admin groups
if ak_is_group_member(request.user, name="authentik Admins"):
# Check if user has MFA configured
from authentik.stages.authenticator_totp.models import TOTPDevice
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
has_totp = TOTPDevice.objects.filter(user=request.user, confirmed=True).exists()
has_webauthn = WebAuthnDevice.objects.filter(user=request.user).exists()
if not (has_totp or has_webauthn):
# Redirect to MFA enrollment
return False
return True
"""
# ============================================
# ALTERNATIVE: SIMPLE GROUP-BASED POLICY
# ============================================
Policy Type: "Expression Policy"
Name: "mfa-required-all-users"
Expression:
"""
# Simply require MFA validation for all authenticated users
return request.user.is_authenticated
"""
# Bind this policy to the mfa-validation stage binding
Conditional Access Policies
Require more authentication for risky situations - new device, unusual location, sensitive app
| Signal | Detection | Response |
|---|---|---|
| New Device | First login from browser/device | Force MFA + email notification |
| New Location | IP geolocation change | Additional verification |
| Impossible Travel | Login from distant location too quickly | Block + alert |
| Sensitive App | Access to high-value application | Always require MFA |
| Failed Attempts | Multiple password failures | CAPTCHA + rate limit |
# ============================================
# RISK-BASED AUTHENTICATION FLOW
# ============================================
# 1. Create GeoIP Policy
Policy Type: "Expression Policy"
Name: "block-high-risk-countries"
Expression:
"""
# Block logins from high-risk countries
BLOCKED_COUNTRIES = ["RU", "CN", "KP", "IR"]
# Get GeoIP data from request context
geo_country = request.context.get("geoip", {}).get("country", "")
if geo_country in BLOCKED_COUNTRIES:
ak_message("Access denied from your location")
return False
return True
"""
# ============================================
# 2. Create New Device Detection Policy
# ============================================
Policy Type: "Expression Policy"
Name: "detect-new-device"
Expression:
"""
# Check if this is a new device/browser
from authentik.core.models import AuthenticatedSession
user = request.user
client_ip = request.context.get("client_ip", "")
# Check for existing sessions from this IP
existing_sessions = AuthenticatedSession.objects.filter(
user=user,
last_ip=client_ip
).exists()
# If new device, set flag for MFA requirement
if not existing_sessions:
request.context["new_device"] = True
return True
"""
# ============================================
# 3. Create Sensitive App Policy
# ============================================
Policy Type: "Expression Policy"
Name: "sensitive-app-mfa"
Expression:
"""
# Always require MFA for sensitive applications
SENSITIVE_APPS = ["vault", "aws-console", "production-db"]
app_slug = request.context.get("application", {}).get("slug", "")
if app_slug in SENSITIVE_APPS:
request.context["require_mfa"] = True
return True
"""
# ============================================
# 4. Bind Policies to Flow Stages
# ============================================
# Add policies to stage bindings:
# - block-high-risk-countries → identification stage
# - detect-new-device → password stage
# - sensitive-app-mfa → mfa-validation stage
Enterprise Directory Integration
Sync users and groups from Active Directory or OpenLDAP
Navigate to Directory → Federation & Social Login → Create → LDAP Source
# ============================================
# LDAP SOURCE CONFIGURATION
# ============================================
Name: "Active Directory"
Slug: "active-directory"
# Connection Settings
Server URI: "ldaps://dc01.corp.local:636" # Use LDAPS!
Enable StartTLS: ☐ # Not needed with LDAPS
TLS Verification Certificate: "(your CA cert)"
# Bind Credentials
Bind CN: "CN=authentik-svc,OU=Service Accounts,DC=corp,DC=local"
Bind Password: "*********"
# Search Settings
Base DN: "DC=corp,DC=local"
# User Settings
User Object Filter: "(objectClass=user)"
User Group Membership Field: "memberOf"
# Group Settings
Group Object Filter: "(objectClass=group)"
Group Membership Field: "member"
# ============================================
# PROPERTY MAPPINGS
# ============================================
# Map AD attributes to Authentik user fields
User Property Mappings:
✅ authentik default LDAP Mapping: mail
✅ authentik default LDAP Mapping: Name
✅ authentik default LDAP Mapping: sAMAccountName (username)
# Map AD groups to Authentik groups
Group Property Mappings:
✅ authentik default LDAP Mapping: cn
# ============================================
# SYNC SETTINGS
# ============================================
Sync Users: ✅
Sync User Password: ☐ # Users auth directly to AD
Sync Groups: ✅
Sync Parent Group: "AD Users" # Optional parent group
# ============================================
# TRIGGER MANUAL SYNC
# ============================================
# In Authentik UI:
# 1. Go to Directory → Federation & Social Login
# 2. Click on your LDAP Source
# 3. Click "Run sync now" button
# Via API:
curl -X POST "https://authentik.yourdomain.com/api/v3/sources/ldap/{source-slug}/sync/" \
-H "Authorization: Bearer YOUR_API_TOKEN"
# ============================================
# SCHEDULED SYNC (via System Tasks)
# ============================================
# Authentik automatically schedules LDAP sync
# Check: System → System Tasks
# Look for: ldap_sync_{source-name}
# Default interval: Every 60 minutes
# Can be customized via environment variable:
AUTHENTIK_LDAP_SYNC_INTERVAL="30" # 30 minutes
# ============================================
# VERIFY SYNC RESULTS
# ============================================
# Check synced users:
# Directory → Users → Filter by "Source: Active Directory"
# Check synced groups:
# Directory → Groups → Look for AD groups
Observability & Alerting
Login attempts, failed authentications, session count, API usage
# ============================================
# ADD TO YOUR docker-compose.yml
# ============================================
prometheus:
image: prom/prometheus:v2.47.0
container_name: prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
networks:
- authentik
grafana:
image: grafana/grafana:10.1.0
container_name: grafana
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_PASSWORD: "admin123"
GF_AUTH_GENERIC_OAUTH_ENABLED: "true"
GF_AUTH_GENERIC_OAUTH_NAME: "Authentik"
GF_AUTH_GENERIC_OAUTH_CLIENT_ID: "grafana"
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: "your-secret"
GF_AUTH_GENERIC_OAUTH_AUTH_URL: "https://authentik.yourdomain.com/application/o/authorize/"
GF_AUTH_GENERIC_OAUTH_TOKEN_URL: "https://authentik.yourdomain.com/application/o/token/"
GF_AUTH_GENERIC_OAUTH_API_URL: "https://authentik.yourdomain.com/application/o/userinfo/"
volumes:
- grafana-data:/var/lib/grafana
depends_on:
- prometheus
networks:
- authentik
volumes:
prometheus-data:
grafana-data:
# ============================================
# PROMETHEUS CONFIGURATION
# prometheus.yml
# ============================================
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
# Authentik metrics
- job_name: 'authentik'
static_configs:
- targets: ['authentik-server:9300']
metrics_path: '/metrics'
# Authentik worker metrics
- job_name: 'authentik-worker'
static_configs:
- targets: ['authentik-worker:9300']
# Prometheus itself
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
| Metric | Description | Alert Threshold |
|---|---|---|
| authentik_login_total | Total login attempts | Spike detection |
| authentik_login_failed_total | Failed login attempts | > 10/min = alert |
| authentik_sessions_total | Active sessions | Capacity planning |
| authentik_flows_execution_time | Flow execution latency | > 5s = investigate |
Multi-Node Deployment
Multiple Authentik servers behind a load balancer for resilience
# ============================================
# HIGH AVAILABILITY AUTHENTIK
# docker-compose.ha.yml
# ============================================
version: "3.8"
services:
# ----------------------------------------
# PostgreSQL - Shared Database
# ----------------------------------------
postgresql:
image: postgres:15
container_name: authentik-db
environment:
POSTGRES_DB: "authentik"
POSTGRES_USER: "authentik"
POSTGRES_PASSWORD: "${PG_PASS}"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U authentik"]
interval: 5s
timeout: 5s
retries: 5
networks:
- authentik
# ----------------------------------------
# Redis - Session & Cache Store
# ----------------------------------------
redis:
image: redis:7-alpine
container_name: authentik-redis
command: redis-server --requirepass ${REDIS_PASS}
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
networks:
- authentik
# ----------------------------------------
# Authentik Server - Node 1
# ----------------------------------------
server-1:
image: ghcr.io/goauthentik/server:2024.2.2
container_name: authentik-server-1
command: server
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_REDIS__PASSWORD: "${REDIS_PASS}"
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: "authentik"
AUTHENTIK_POSTGRESQL__PASSWORD: "${PG_PASS}"
AUTHENTIK_POSTGRESQL__NAME: "authentik"
AUTHENTIK_SECRET_KEY: "${AUTHENTIK_SECRET_KEY}"
labels:
- "traefik.enable=true"
- "traefik.http.services.authentik.loadbalancer.server.port=9000"
depends_on:
- postgresql
- redis
networks:
- authentik
# ----------------------------------------
# Authentik Server - Node 2
# ----------------------------------------
server-2:
image: ghcr.io/goauthentik/server:2024.2.2
container_name: authentik-server-2
command: server
environment:
# Same environment as server-1
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_REDIS__PASSWORD: "${REDIS_PASS}"
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: "authentik"
AUTHENTIK_POSTGRESQL__PASSWORD: "${PG_PASS}"
AUTHENTIK_POSTGRESQL__NAME: "authentik"
AUTHENTIK_SECRET_KEY: "${AUTHENTIK_SECRET_KEY}"
labels:
- "traefik.enable=true"
- "traefik.http.services.authentik.loadbalancer.server.port=9000"
depends_on:
- postgresql
- redis
networks:
- authentik
# ----------------------------------------
# Shared Worker (only need one)
# ----------------------------------------
worker:
image: ghcr.io/goauthentik/server:2024.2.2
container_name: authentik-worker
command: worker
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_REDIS__PASSWORD: "${REDIS_PASS}"
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: "authentik"
AUTHENTIK_POSTGRESQL__PASSWORD: "${PG_PASS}"
AUTHENTIK_POSTGRESQL__NAME: "authentik"
AUTHENTIK_SECRET_KEY: "${AUTHENTIK_SECRET_KEY}"
depends_on:
- postgresql
- redis
networks:
- authentik
volumes:
postgres-data:
redis-data:
networks:
authentik:
Validate your enterprise SSO
| Interview Question | Your Answer |
|---|---|
| "How do you automate user provisioning?" | SCIM 2.0 protocol. IdP pushes user lifecycle events (create/update/delete) to applications. Implemented with Authentik SCIM providers. |
| "What's the most secure MFA?" | FIDO2/WebAuthn with hardware security keys. Phishing-resistant because authentication is bound to the origin. No codes to intercept. |
| "How do you implement conditional access?" | Risk-based policies evaluating context: device, location, time, app sensitivity. Higher risk = stronger authentication requirements. |
| "How do you ensure SSO availability?" | Multi-node deployment with shared PostgreSQL and Redis. Load balancer distributes traffic. Automatic failover if node fails. |