Authentik SSO as a Zero-Trust Gateway: Replacing VPNs with Identity-Aware Reverse Proxies

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:

  1. Lateral movement: A compromised VPN credential gives access to the entire network segment. An identity-aware proxy gives access only to explicitly authorized applications.
  1. Device posture: VPNs authenticate users, not devices. Authentik policies can evaluate device attributes, session age, and risk signals before granting access.
  1. 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:

  1. 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.
  1. 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.
  1. Phase 3 (Week 5-6): Configure per-application policies and MFA requirements. Monitor access patterns in the SIEM.
  1. 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.
  1. 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.

Scroll to Top