VPNs grant network-level access. Once you are on the VPN, you can reach anything the network allows. This is fundamentally at odds with zero-trust architecture, where every request must be authenticated and authorized regardless of network position. Authentik — a self-hosted identity provider — can replace VPN-based access with identity-aware reverse proxies that enforce per-application policies, MFA, and session controls. This guide covers practical deployment.
Why Replace VPNs with Identity-Aware Proxies
The VPN model has three problems that identity-aware proxies solve:
- Lateral movement: A compromised VPN credential gives access to the entire network segment. An identity-aware proxy gives access only to explicitly authorized applications.
- Device posture: VPNs authenticate users, not devices. Authentik policies can evaluate device attributes, session age, and risk signals before granting access.
- Audit granularity: VPN logs show “user connected at timestamp.” Identity-aware proxy logs show “user accessed application X, endpoint Y, at timestamp Z, from device with attributes A, B, C.”
Deployment Architecture
┌──────────────┐
│ Internet │
└──────┬───────┘
│
┌──────▼───────────────────────────────────────┐
│ reverse-proxy01 (Apache or Traefik) │
│ TLS termination, routes to Authentik or │
│ directly to apps based on auth state │
└──────┬──────────────────┬────────────────────┘
│ │
┌──────▼───────┐ ┌──────▼──────────────────┐
│ authentik01 │ │ Application backends │
│ (IdP + proxy │ │ app01 (Grafana) │
│ provider) │ │ app02 (Wiki) │
│ │ │ app03 (Internal API) │
└──────────────┘ └─────────────────────────┘
Docker Compose Deployment
# docker-compose.yml for Authentik
version: "3.9"
services:
postgresql:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "${PG_PASSWORD}"
healthcheck:
test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: --save 60 1 --loglevel warning
volumes:
- redisdata:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 10s
timeout: 5s
retries: 5
authentik-server:
image: ghcr.io/goauthentik/server:2025.2
command: server
environment:
AUTHENTIK_SECRET_KEY: "${AUTHENTIK_SECRET_KEY}"
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: "${PG_PASSWORD}"
# Disable outbound connections for air-gapped
AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
ports:
- "9000:9000" # HTTP
- "9443:9443" # HTTPS
volumes:
- authentik-media:/media
- authentik-templates:/templates
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_healthy
authentik-worker:
image: ghcr.io/goauthentik/server:2025.2
command: worker
environment:
AUTHENTIK_SECRET_KEY: "${AUTHENTIK_SECRET_KEY}"
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: "${PG_PASSWORD}"
volumes:
- authentik-media:/media
- authentik-templates:/templates
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_healthy
volumes:
pgdata:
redisdata:
authentik-media:
authentik-templates:
Generate secrets before first launch:
# Generate Authentik secret key (used for cookie signing, token encryption)
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -hex 64)" >> .env
echo "PG_PASSWORD=$(openssl rand -base64 36)" >> .env
OIDC Provider Configuration
For applications that support OIDC natively (Grafana, GitLab, Nextcloud), create an OIDC provider in Authentik:
# Authentik OIDC Provider settings (via Admin UI or API)
provider:
name: "grafana-oidc"
type: "oauth2"
authorization_flow: "default-provider-authorization-explicit-consent"
client_type: "confidential"
client_id: "grafana"
client_secret: "GENERATED_SECRET" # Store in secrets manager
redirect_uris:
- "https://grafana.example-corp.com/login/generic_oauth"
signing_key: "authentik Self-signed Certificate" # Use RS256
access_token_validity: "minutes=15"
refresh_token_validity: "days=7"
scopes:
- "openid"
- "email"
- "profile"
- "groups"
sub_mode: "user_id"
Configure the application (Grafana example):
# grafana.ini — OIDC configuration
[auth.generic_oauth]
enabled = true
name = Authentik
client_id = grafana
client_secret = GENERATED_SECRET
scopes = openid email profile groups
auth_url = https://login.example-corp.com/application/o/authorize/
token_url = https://login.example-corp.com/application/o/token/
api_url = https://login.example-corp.com/application/o/userinfo/
role_attribute_path = contains(groups[], 'grafana-admin') && 'Admin' || contains(groups[], 'grafana-editor') && 'Editor' || 'Viewer'
allow_sign_up = true
auto_login = false
use_pkce = true
Proxy Provider: Forward Auth vs. Proxy Auth
For applications that do not support OIDC (legacy apps, static sites, custom tools), Authentik provides two proxy modes:
Forward Auth Mode
The reverse proxy sends a subrequest to Authentik before serving the application. This is the preferred mode for Apache and Traefik.
# Apache virtual host with Authentik forward auth
<VirtualHost :443>
ServerName wiki.example-corp.com
SSLEngine on
SSLCertificateFile /etc/ssl/certs/wiki.example-corp.com.pem
SSLCertificateKeyFile /etc/ssl/private/wiki.example-corp.com.key
# Forward auth subrequest to Authentik
<Location />
AuthType None
Require all granted
# Subrequest to Authentik outpost
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/outpost.goauthentik.io/
RewriteRule ^(.)$ - [E=AUTH_REQUEST:yes]
# Check authentication via subrequest
<If "%{ENV:AUTH_REQUEST} == 'yes'">
# Use mod_auth_openidc or forward-auth pattern
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Host "%{HTTP_HOST}e"
# Verify with Authentik outpost
RewriteRule .* - [E=AUTHN_CHECK:/outpost.goauthentik.io/auth/apache]
</If>
ProxyPass http://wiki-backend01.internal:8080/
ProxyPassReverse http://wiki-backend01.internal:8080/
# Pass authenticated user info to backend
RequestHeader set X-authentik-username "%{AUTHENTICATE_X_AUTHENTIK_USERNAME}e"
RequestHeader set X-authentik-groups "%{AUTHENTICATE_X_AUTHENTIK_GROUPS}e"
RequestHeader set X-authentik-email "%{AUTHENTICATE_X_AUTHENTIK_EMAIL}e"
</Location>
# Authentik outpost endpoint
<Location /outpost.goauthentik.io>
ProxyPass http://authentik01.internal:9000/outpost.goauthentik.io
ProxyPassReverse http://authentik01.internal:9000/outpost.goauthentik.io
RequestHeader set X-Original-URL "%{REQUEST_SCHEME}s://%{HTTP_HOST}s%{REQUEST_URI}s"
</Location>
</VirtualHost>
For Traefik, forward auth is cleaner:
# Traefik dynamic configuration
http:
middlewares:
authentik:
forwardAuth:
address: "http://authentik01.internal:9000/outpost.goauthentik.io/auth/traefik"
trustForwardHeader: true
authResponseHeaders:
- X-authentik-username
- X-authentik-groups
- X-authentik-email
- X-authentik-name
- X-authentik-uid
routers:
wiki:
rule: "Host(wiki.example-corp.com)"
middlewares:
- authentik
service: wiki-backend
tls:
certResolver: letsencrypt
services:
wiki-backend:
loadBalancer:
servers:
- url: "http://wiki-backend01.internal:8080"
Proxy Auth Mode
In proxy auth mode, Authentik acts as the reverse proxy itself — it terminates TLS, authenticates the user, and forwards the request to the backend. This is simpler but adds Authentik as a performance bottleneck.
Use proxy auth for low-traffic internal tools. Use forward auth for anything with meaningful traffic volume.
Per-Application Access Policies
Authentik policies are the core of the zero-trust model. Create layered policies:
# Authentik policy (expression policy, written in Python)
# Policy: Require MFA + group membership + session age < 8 hours
# Check group membership
if not request.user.ak_groups.filter(name="wiki-users").exists():
ak_message("You are not authorized to access this application.")
return False
# Check MFA enrollment
from authentik.stages.authenticator.models import Device
mfa_devices = Device.objects.filter(user=request.user, confirmed=True)
if not mfa_devices.exists():
ak_message("MFA enrollment is required. Please configure an authenticator.")
return False
# Check session age (force re-auth after 8 hours)
from datetime import timedelta
from django.utils import timezone
session_age = timezone.now() - request.user.last_login
if session_age > timedelta(hours=8):
ak_message("Session expired. Please re-authenticate.")
return False
return True
MFA Enforcement Strategy
Configure MFA in stages rather than as a blanket requirement:
# Authentication flow stages
flows:
- name: "default-authentication-flow"
stages:
1: identification-stage # Username/email
2: password-stage # Password verification
3: mfa-validation-stage # TOTP/WebAuthn/SMS
4: session-duration-stage # Set session parameters
# MFA stage configuration
stages:
mfa-validation:
type: authenticator_validate
device_classes:
- authentik_stages_authenticator_totp.TOTPDevice
- authentik_stages_authenticator_webauthn.WebAuthnDevice
# Explicitly exclude SMS — SIM swap risk
not_configured_action: "deny" # Block users without MFA
# Or: "configure" to force enrollment on first login
For high-security applications, require WebAuthn (hardware keys) and reject TOTP:
# Expression policy: Require hardware security key for admin apps
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
webauthn_devices = WebAuthnDevice.objects.filter(
user=request.user,
confirmed=True
)
if not webauthn_devices.exists():
ak_message("This application requires a hardware security key (WebAuthn).")
return False
return True
Session Management
Control session lifetime and behavior per application:
# Session settings in Authentik provider
session:
# Maximum session duration (absolute timeout)
session_duration: "hours=8"
# Idle timeout (re-auth after inactivity)
# Implemented via policy checking last_login
idle_timeout: "minutes=30"
# Invalidate all sessions on password change
invalidate_on_password_change: true
# Single-session enforcement (optional, prevents concurrent logins)
# Implemented via custom signal handler
Implement session monitoring by querying the Authentik API:
# List all active sessions for audit
curl -s -H "Authorization: Bearer ${AUTHENTIK_API_TOKEN}"
"https://login.example-corp.com/api/v3/core/tokens/?ordering=-expires"
| jq '.results[] | {user: .user.username, app: .description, expires: .expires}'
# Revoke a specific user's sessions (incident response)
curl -X DELETE -H "Authorization: Bearer ${AUTHENTIK_API_TOKEN}"
"https://login.example-corp.com/api/v3/core/tokens/${TOKEN_ID}/"
Monitoring and Alerting
Push Authentik events to your SIEM for security monitoring:
# Authentik notification transport — webhook to SIEM
notification_transports:
- name: "siem-webhook"
mode: "webhook"
webhook_url: "https://siem01.internal:514/api/v1/events"
webhook_mapping:
severity: "event.severity"
message: "event.action"
user: "event.user.username"
ip: "event.client_ip"
app: "event.context.app"
# Notification rules
notification_rules:
- name: "Failed login attempts"
transports: ["siem-webhook"]
severity: "warning"
group: "authentik Admins"
events:
- "login_failed"
- "authorization_denied"
- name: "Admin actions"
transports: ["siem-webhook"]
severity: "notice"
group: "authentik Admins"
events:
- "model_created"
- "model_updated"
- "model_deleted"
- "policy_execution"
Key events to alert on:
- Brute force: 5+ failed logins from the same IP within 5 minutes
- Credential stuffing: Failed logins across multiple accounts from the same source
- Session anomaly: Active session from a new country/ASN
- Policy bypass: Any authorization granted without MFA when MFA should be required
- Admin activity: Any change to flows, providers, or policies
Migration Path from VPN
Migrating from VPN to identity-aware proxy is not a single cutover. Use a phased approach:
- Phase 1 (Week 1-2): Deploy Authentik alongside existing VPN. Configure OIDC for applications that already support it (Grafana, GitLab, etc.). Users access these apps directly without VPN.
- Phase 2 (Week 3-4): Add forward-auth proxy for internal web applications that do not support OIDC. Users can access these through the proxy or the VPN.
- Phase 3 (Week 5-6): Configure per-application policies and MFA requirements. Monitor access patterns in the SIEM.
- Phase 4 (Week 7-8): Restrict VPN access to SSH-only (for hosts that require direct network access). All web applications route through the identity-aware proxy.
- Phase 5 (Week 9+): Evaluate remaining VPN use cases. For SSH access, consider deploying a bastion host with Authentik-backed certificate authentication instead.
The goal is not necessarily to eliminate the VPN entirely — some use cases (database access, custom protocols) still require network-level connectivity. The goal is to remove the VPN as the primary access mechanism for web applications, where identity-aware proxies provide better security, auditability, and user experience.
Identity-aware proxies shift the security perimeter from the network edge to the application layer. Every request is authenticated, every session is policy-evaluated, and every access is logged with full context. Combined with MFA enforcement and session controls, this is a practical implementation of zero-trust that works with self-hosted infrastructure.
