Homelab
Proof

U2 / Networking and remote access

Authelia vs authentik behind Caddy forward_auth, measured

Tested on Authelia 4.39.28

Test result

Both gated the backend correctly: no cookie got a redirect, and a scripted login got 200 with the user's name in a header. Authelia idled at 60.2 MiB (74.6 MiB with Caddy and the backend) and answered 1.82 s after start; authentik idled at 822.6 MiB across three containers (892.4 MiB across five with Caddy and the backend), answered 110.53 s after start, and needed three API write calls to wire up a protected app. The two times are single first-start measurements.

Line drawing: a turnstile gate in front of a small server, with a key card reader on a post.

The question

Which one is worth running to put a login in front of a self-hosted web app that has none? This test puts each behind the same Caddy forward_auth in front of the same echo backend (traefik/whoami), then checks the two things that define the job: a request without a session must not reach the app, and a request with one must.

Does the gate work

What the gate did, request by request
RequestAuthelia 4.39.28 behind Caddy forward_authauthentik 2026.8.5 (embedded outpost) behind Caddy forward_auth
GET protected page, no cookie302, Location auth.lab.internal/302, Location authentik.lab.internal/application/o/authorize/
Same with Accept: application/json401302
Forged session cookie302302
Login by scriptPOST /api/firstfactor with the right password: 200; with a wrong password: 401flow executor API, two POSTs (identification, password); session confirmed by GET /api/v3/core/users/me/: {"username": "akadmin", "is_current": true}
GET protected page with the session200, backend saw Remote-User: lab200 after 3 redirects, backend saw X-Authentik-Username: akadmin

Backend is traefik/whoami v1.12.0, which echoes the request headers it received. Caddy 2.11.7 terminates TLS with its internal CA; curl trusts that root with --cacert. Login for authentik follows the redirect chain whoami -> outpost -> authorize -> callback -> whoami.

Without a session, both answered 302 and sent the client to their own login. They differ for non-browser clients: Authelia answered 401 when the request asked for JSON, authentik still answered 302. A made-up session cookie got a 302 from both. With a real session, the backend received the identity as a header (Remote-User: lab from Authelia, X-Authentik-Username: akadmin from authentik) and answered 200.

The Authelia login is one JSON POST to /api/firstfactor. The authentik login runs through its flow executor API: a POST for the identification stage, a GET for the next challenge, a POST for the password, and a final GET. The script then followed the redirect chain from the protected URL through the outpost and authorize endpoints and back, and that chain ended in 200 after 3 redirects. The wrong-password check was only run against Authelia (401).

What each costs

Resources and speed during this test (whole stack including Caddy and the whoami backend)
StackContainer start to first 200 from the auth service (one run each)RAM idle, MiB, auth service containers onlyRAM idle, MiB, sum of all containersRAM during 600-request burst, MiBIdle RAM per container, MiBAuthenticated request, ms, median of 3 batches of 50 (range)Burst: 600 requests, 8 parallel
Authelia 4.39.281.82 s60.274.6212.7 (max 213.0)fa-caddy-1 13; fa-authelia-1 60; fa-whoami-1 24.6 (4.4 to 5.3)600/600 OK in 1.88 s
authentik 2026.8.5110.53 s822.6892.4936.4 (max 936.7)fa-caddy-1 58; fa-authentik-server-1 443; fa-authentik-worker-1 304; fa-postgresql-1 76; fa-whoami-1 126.6 (6.3 to 6.6)600/600 OK in 4.67 s

Idle RAM is the median of 12 docker stats samples, taken 30 s (Authelia) or 2 minutes (authentik) after it answered. Requests are sequential curl calls, each with a new TLS connection, through Caddy to the auth service and then the backend. VM 4 vCPU, 3 GB. The readiness times are single first-start measurements and use different endpoints (Authelia /api/health, authentik /-/health/ready, which waits for its database migrations). Caddy and whoami used more memory in the authentik run (58-60 and 11.6 MiB) than in the Authelia run (12.8 and 1.7 MiB); the test did not find out why.

The “sum of all containers” column includes Caddy and the whoami backend; the column before it counts the auth service containers only (Authelia; authentik server, worker and PostgreSQL). Caddy and whoami also used more memory in the authentik run (58 to 60 and 11.6 MiB) than in the Authelia run (12.8 and 1.7 MiB), which the test did not explain, so the whole-stack gap is about 55 MiB larger than the gap between the auth services themselves. The stack table below comes from the project’s separate idle measurement of each service alone (45.5 MiB for Authelia, 799.2 MiB for authentik); it was taken at a different moment, which is one reason the figures differ from the ones above.

Idle footprint, measured on the reference test box. Median of 12 samples after settling.
StackVersionRAM at idleCPU at idleImages on diskReady after
Authelia4.39.2845.5 MiB0.02%109.9 MB2.2 s
authentik2026.8.5799 MiB1.58%2356.7 MB100.1 s

The request timing is a sanity figure, not a benchmark: sequential curl calls, a fresh TLS connection each, one VM. Both stayed in single-digit milliseconds. The burst row (600 requests, 8 at a time) completed with all 600 returning 200 on both.

authentik ran inside the 3 GB VM used for every test here, with 892.4 MiB in containers at idle and 936.4 MiB during the burst. The test did not push it harder or try a smaller VM.

How each was wired

Authelia is two files and a Caddy block. The password hash in users.yml was generated in the test with authelia crypto hash generate argon2, not copied from the stack’s placeholder. The cookie domain is lab.internal and authelia_url has to use the https:// scheme (per the Authelia session configuration), which is why Caddy serves the portal and the app over its internal CA here.

lab/tests/forward-auth/files/authelia.configuration.yml
Show the full file
server:
  address: tcp://0.0.0.0:9091
log:
  level: info
authentication_backend:
  file:
    path: /config/users.yml
access_control:
  default_policy: deny
  rules:
    - domain: whoami.lab.internal
      policy: one_factor
session:
  cookies:
    - domain: lab.internal
      authelia_url: https://auth.lab.internal
      default_redirection_url: https://whoami.lab.internal
storage:
  local:
    path: /data/db.sqlite3
notifier:
  filesystem:
    filename: /data/notification.txt
lab/tests/forward-auth/files/Caddyfile.authelia
Show the full file
auth.lab.internal {
	tls internal
	reverse_proxy authelia:9091
}
whoami.lab.internal {
	tls internal
	forward_auth authelia:9091 {
		uri /api/authz/forward-auth
		copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
	}
	reverse_proxy whoami:80
}

The test did not use a config file for authentik. After the stack is up, the app exists only once an API client has created a proxy provider, an application, and attached the provider to the embedded outpost, and set the outpost’s authentik_host to the public URL. That is three write calls (create provider, create application, patch the outpost with the provider and host), preceded by lookups of the two default flows and the outpost. The test did this with the bootstrap token (AUTHENTIK_BOOTSTRAP_TOKEN, AUTHENTIK_BOOTSTRAP_PASSWORD in the compose file) and the calls are in the script. Whether authentik can be set up declaratively instead was not tested. Caddy also has to route /outpost.goauthentik.io/* to authentik.

lab/tests/forward-auth/files/Caddyfile.authentik
Show the full file
authentik.lab.internal {
	tls internal
	reverse_proxy authentik-server:9000
}
whoami.lab.internal {
	tls internal
	route {
		reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
		forward_auth authentik-server:9000 {
			uri /outpost.goauthentik.io/auth/caddy
			copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name X-Authentik-Uid
			trusted_proxies private_ranges
		}
		reverse_proxy whoami:80
	}
}
lab/tests/forward-auth/files/compose.authentik.yml

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
x-env: &env
  AUTHENTIK_POSTGRESQL__HOST: postgresql
  AUTHENTIK_POSTGRESQL__NAME: authentik
  AUTHENTIK_POSTGRESQL__USER: authentik
  AUTHENTIK_POSTGRESQL__PASSWORD: lab-only-not-a-secret
  AUTHENTIK_SECRET_KEY: lab-only-not-a-secret-lab-only-not-a-secret-lab-only
  AUTHENTIK_BOOTSTRAP_PASSWORD: lab-only-not-a-secret
  AUTHENTIK_BOOTSTRAP_TOKEN: lab-only-not-a-secret-bootstrap-token-0001
  AUTHENTIK_BOOTSTRAP_EMAIL: akadmin@lab.internal
services:
  whoami:
    image: traefik/whoami:v1.12.0
  postgresql:
    image: docker.io/library/postgres:16-alpine
    environment:
      POSTGRES_DB: authentik
      POSTGRES_USER: authentik
      POSTGRES_PASSWORD: lab-only-not-a-secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"]
      interval: 10s
      retries: 5
      start_period: 20s
      timeout: 5s
    volumes:
      - database:/var/lib/postgresql/data
  authentik-server:
    image: ghcr.io/goauthentik/server:2026.8.5
    command: server
    shm_size: 512mb
    depends_on:
      postgresql: { condition: service_healthy }
    environment: *env
    volumes:
      - ./data:/data
  authentik-worker:
    image: ghcr.io/goauthentik/server:2026.8.5
    command: worker
    shm_size: 512mb
    user: root
    depends_on:
      postgresql: { condition: service_healthy }
    environment: *env
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data:/data
      - ./certs:/certs
  caddy:
    image: caddy:2.11.7
    ports: ["127.0.0.1:443:443"]
    volumes:
      - ./Caddyfile.authentik:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
    depends_on: [authentik-server, whoami]
    networks: { default: { aliases: [authentik.lab.internal, whoami.lab.internal] } }
volumes:
  database:
  caddy_data:

The full script, including every request shown above:

lab/tests/forward-auth/fa.sh

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env bash
# Runs inside the VM from ~/fa. Raw outputs in ~/fa/raw. Two phases, Authelia then authentik, each behind Caddy forward_auth protecting traefik/whoami.
set -u
cd "$HOME/fa"; mkdir -p raw
now() { date +%s.%N; }
el() { python3 -c "print(round($2 - $1, 2))"; }
log() { echo "== $* $(date -u +%T)"; }
RES=(--resolve auth.lab.internal:443:127.0.0.1 --resolve whoami.lab.internal:443:127.0.0.1 --resolve authentik.lab.internal:443:127.0.0.1)
PW=lab-only-not-a-secret

sample_stats() { # sample_stats <outfile> <n>: docker stats of every running container, n samples
  : > "$1"; for _ in $(seq 1 "$2"); do docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}' >> "$1"; done
}
authed_latency() { # authed_latency <label> <cookie-jar> <url>: 3 batches of 50 sequential authenticated requests
  for b in 1 2 3; do
    for _ in $(seq 1 50); do curl -s "${RES[@]}" --cacert root.crt -b "$2" -o /dev/null -w '%{http_code} %{time_total}\n' "$3"; done > raw/$1.latency.batch$b.txt
  done
}
burst() { # burst <label> <cookie-jar> <url>: 600 authenticated requests, 8 in parallel, with docker stats sampling
  local flag=raw/.sampling; touch $flag; : > raw/$1.burst.stats.txt
  ( while [ -e $flag ]; do docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}' >> raw/$1.burst.stats.txt; done ) &
  local sp=$!
  local T0; T0=$(now)
  seq 1 600 | xargs -P 8 -I{} curl -s "${RES[@]}" --cacert root.crt -b "$2" -o /dev/null -w '%{http_code}\n' "$3" > raw/$1.burst.codes.txt
  echo "burst_seconds $(el "$T0" "$(now)")" > raw/$1.burst.time.txt
  rm -f $flag; wait $sp
}

# ======================= Authelia
log authelia
export COMPOSE_PROJECT_NAME=fa
docker pull -q authelia/authelia:4.39.28 > /dev/null; docker pull -q caddy:2.11.7 > /dev/null; docker pull -q traefik/whoami:v1.12.0 > /dev/null
docker run --rm authelia/authelia:4.39.28 authelia crypto hash generate argon2 --password "$PW" > raw/authelia.hash-output.txt 2>&1
HASH=$(sed -n 's/^Digest: //p' raw/authelia.hash-output.txt)
python3 - "$HASH" <<'PY'
import sys
t = open("authelia.users.yml.tmpl").read().replace("__HASH__", sys.argv[1])
open("authelia.users.yml", "w").write(t)
PY
sed 's/\$argon2id.*/<hash>/' raw/authelia.hash-output.txt > raw/authelia.hash-output.txt.tmp && mv raw/authelia.hash-output.txt.tmp raw/authelia.hash-output.txt
vm_before=$(free -m | awk 'NR==2{print $3}'); echo "vm_used_mb_before $vm_before" > raw/authelia.vm-before.txt
T0=$(now)
docker compose -f compose.authelia.yml up -d > raw/authelia.compose-up.txt 2>&1
until [ "$(curl -ks -m2 "${RES[@]}" -o /dev/null -w '%{http_code}' https://auth.lab.internal/api/health)" = 200 ]; do sleep 0.25; done
echo "ready_seconds $(el "$T0" "$(now)")" > raw/authelia.ready.txt
docker compose -f compose.authelia.yml cp caddy:/data/caddy/pki/authorities/local/root.crt root.crt > /dev/null 2>&1
sleep 30
sample_stats raw/authelia.idle.txt 12
C=(curl -s "${RES[@]}" --cacert root.crt)
"${C[@]}" -i https://whoami.lab.internal/ > raw/authelia.1-unauthenticated.txt 2>&1
"${C[@]}" -i -H 'Accept: application/json' https://whoami.lab.internal/ > raw/authelia.1b-unauthenticated-json.txt 2>&1
"${C[@]}" -i -H 'Cookie: authelia_session=forged-value' https://whoami.lab.internal/ > raw/authelia.2-forged-cookie.txt 2>&1
"${C[@]}" -i -X POST https://auth.lab.internal/api/firstfactor -H 'content-type: application/json' \
  -d '{"username":"lab","password":"wrong-password","keepMeLoggedIn":false}' > raw/authelia.3-wrong-password.txt 2>&1
"${C[@]}" -i -c authelia.jar -X POST https://auth.lab.internal/api/firstfactor -H 'content-type: application/json' \
  -d "{\"username\":\"lab\",\"password\":\"$PW\",\"keepMeLoggedIn\":false,\"targetURL\":\"https://whoami.lab.internal/\"}" > raw/authelia.4-login.txt 2>&1
"${C[@]}" -i -b authelia.jar https://whoami.lab.internal/ > raw/authelia.5-authenticated.txt 2>&1
sed -E 's/(authelia_session\s+)\S+/\1<redacted>/' authelia.jar > raw/authelia.cookie-jar.redacted.txt
authed_latency authelia authelia.jar https://whoami.lab.internal/
burst authelia authelia.jar https://whoami.lab.internal/
docker compose -f compose.authelia.yml logs --no-color --tail=30 authelia > raw/authelia.logs-tail.txt 2>&1
docker compose -f compose.authelia.yml down -v > /dev/null 2>&1

# ======================= authentik
log authentik
docker pull -q ghcr.io/goauthentik/server:2026.8.5 > /dev/null; docker pull -q postgres:16-alpine > /dev/null
mkdir -p data certs; chmod 777 data certs
T0=$(now)
docker compose -f compose.authentik.yml up -d > raw/authentik.compose-up.txt 2>&1
until [ "$(curl -ks -m3 "${RES[@]}" -o /dev/null -w '%{http_code}' https://authentik.lab.internal/-/health/ready/)" = 204 ] || [ "$(curl -ks -m3 "${RES[@]}" -o /dev/null -w '%{http_code}' https://authentik.lab.internal/-/health/ready/)" = 200 ]; do
  sleep 2; [ "$(el "$T0" "$(now)" | cut -d. -f1)" -gt 600 ] && { echo "authentik not ready in 600 s" > raw/authentik.NOT-READY.txt; break; }
done
echo "ready_seconds $(el "$T0" "$(now)")" > raw/authentik.ready.txt
docker compose -f compose.authentik.yml cp caddy:/data/caddy/pki/authorities/local/root.crt root.crt > /dev/null 2>&1
sleep 120   # let the worker finish its first-run tasks
sample_stats raw/authentik.idle.txt 12
free -m > raw/authentik.free.txt
C=(curl -s "${RES[@]}" --cacert root.crt)
TOKEN=lab-only-not-a-secret-bootstrap-token-0001
AK=("${C[@]}" -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json')
API=https://authentik.lab.internal/api/v3
"${AK[@]}" "$API/core/users/me/" > raw/authentik.api-me.json 2>&1
AUTHZ=$("${AK[@]}" "$API/flows/instances/?slug=default-provider-authorization-implicit-consent" | jq -r '.results[0].pk')
INVAL=$("${AK[@]}" "$API/flows/instances/?slug=default-provider-invalidation-flow" | jq -r '.results[0].pk')
echo "authz=$AUTHZ inval=$INVAL" > raw/authentik.flow-pks.txt
"${AK[@]}" -X POST "$API/providers/proxy/" -d "{\"name\":\"whoami\",\"authorization_flow\":\"$AUTHZ\",\"invalidation_flow\":\"$INVAL\",\"external_host\":\"https://whoami.lab.internal\",\"mode\":\"forward_single\"}" > raw/authentik.create-provider.json 2>&1
PID=$(jq -r '.pk' raw/authentik.create-provider.json)
"${AK[@]}" -X POST "$API/core/applications/" -d "{\"name\":\"whoami\",\"slug\":\"whoami\",\"provider\":$PID}" > raw/authentik.create-app.json 2>&1
"${AK[@]}" "$API/outposts/instances/" > raw/authentik.outposts.json 2>&1
OID=$(jq -r '.results[] | select(.managed=="goauthentik.io/outposts/embedded") | .pk' raw/authentik.outposts.json)
CFG=$(jq -c '.results[] | select(.managed=="goauthentik.io/outposts/embedded") | .config | .authentik_host="https://authentik.lab.internal"' raw/authentik.outposts.json)
"${AK[@]}" -X PATCH "$API/outposts/instances/$OID/" -d "{\"providers\":[$PID],\"config\":$CFG}" > raw/authentik.patch-outpost.json 2>&1
for _ in $(seq 1 60); do
  code=$("${C[@]}" -o /dev/null -w '%{http_code}' https://whoami.lab.internal/)
  [ "$code" = 302 ] && break; sleep 2
done
echo "first 302 after wiring the outpost: $code" > raw/authentik.outpost-wired.txt
"${C[@]}" -i https://whoami.lab.internal/ > raw/authentik.1-unauthenticated.txt 2>&1
"${C[@]}" -i -H 'Accept: application/json' https://whoami.lab.internal/ > raw/authentik.1b-unauthenticated-json.txt 2>&1
"${C[@]}" -i -H 'Cookie: authentik_proxy_forged=forged-value' https://whoami.lab.internal/ > raw/authentik.2-forged-cookie.txt 2>&1
# scripted login through the flow executor API
FL=$API/flows/executor/default-authentication-flow/
"${C[@]}" -c ak.jar -b ak.jar "$FL" > raw/authentik.login-0.json 2>&1
# the executor answers a POST with a 302 back to its own URL when the flow moves on; a GET then returns the next challenge
"${C[@]}" -c ak.jar -b ak.jar -X POST "$FL" -H 'content-type: application/json' -d '{"component":"ak-stage-identification","uid_field":"akadmin"}' -o /dev/null -w 'identification POST -> %{http_code}\n' > raw/authentik.login-1.json 2>&1
"${C[@]}" -c ak.jar -b ak.jar "$FL" >> raw/authentik.login-1.json 2>&1
"${C[@]}" -c ak.jar -b ak.jar -X POST "$FL" -H 'content-type: application/json' -d "{\"component\":\"ak-stage-password\",\"password\":\"$PW\"}" -o /dev/null -w 'password POST -> %{http_code}\n' > raw/authentik.login-2.json 2>&1
"${C[@]}" -L -c ak.jar -b ak.jar "$FL" >> raw/authentik.login-2.json 2>&1
"${C[@]}" -b ak.jar "$API/core/users/me/" | jq '{username: .user.username, is_current: .user.is_current}' > raw/authentik.login-3-me.json 2>&1
# follow the whole redirect chain with the session cookie: whoami -> outpost start -> authorize -> callback -> whoami
"${C[@]}" -L -i -c ak.jar -b ak.jar -w '\nFINAL %{http_code} after %{num_redirects} redirects, url_effective=%{url_effective}\n' https://whoami.lab.internal/ > raw/authentik.4-authenticated-follow.txt 2>&1
"${C[@]}" -i -b ak.jar https://whoami.lab.internal/ > raw/authentik.5-authenticated-cookie-only.txt 2>&1
sed -E 's/(\s)[A-Za-z0-9._~-]{40,}$/\1<redacted>/' ak.jar > raw/authentik.cookie-jar.redacted.txt
authed_latency authentik ak.jar https://whoami.lab.internal/
burst authentik ak.jar https://whoami.lab.internal/
docker compose -f compose.authentik.yml logs --no-color --tail=20 authentik-server > raw/authentik.logs-tail.txt 2>&1
docker compose -f compose.authentik.yml down -v > /dev/null 2>&1
echo done > raw/DONE

What went wrong or surprised

  • The first scripted authentik login failed. Each POST to the flow executor answers 302 back to the same URL, and the first version of the script followed that redirect with the POST method still set, which re-sent the identification payload to the password stage. The fix was a plain POST followed by a separate GET. The failed attempt is why the script comments on it.
  • Authelia’s session cookie was set for the parent domain lab.internal and the portal URL was https. This test used HTTPS throughout and did not try plain HTTP.
  • authentik took 110.53 s to the first health answer here; the project’s stack-only run recorded 100.1 s. Authelia’s 1.82 s (2.2 s in the stack-only run) and these times are single first starts, measured against different endpoints (Authelia’s /api/health, authentik’s /-/health/ready), so they show the order of magnitude, not a precise ratio. First start runs database migrations, so a fresh install should be expected to take minutes, not seconds, to be usable.

What this test did not cover

  • Second factors, passkeys, device policies, and password reset. Both were tested with one factor only.
  • OIDC, SAML, LDAP and any app that speaks them directly. Only forward auth was exercised.
  • Wrong password and lockout on authentik, and brute-force protection on either.
  • Real browsers: all requests were curl, with --resolve and the internal CA root passed explicitly.
  • Upgrades, backups of either database, and what happens to sessions after a restart.
  • High availability and anything above a few hundred requests.
  • Group-based rules. Authelia’s access control had one rule and authentik had one application with the default policy.

Sources and how this was made

lab/tests/forward-auth/run.sh
Show the full file
#!/usr/bin/env bash
# forward-auth: Authelia and authentik, each behind Caddy forward_auth protecting traefik/whoami; scripted login proves the gate.
TEST_ID=forward-auth; VM_NAME=b-forward-auth
source "$(dirname "$0")/../access-common.sh"
HERE="$ROOT/lab/tests/$TEST_ID"
rm -rf "$RAW"; mkdir -p "$RAW"
vm_start
capture_env
vm ssh 'rm -rf ~/fa; mkdir -p ~/fa/raw'
vm scp-to "$HERE/files/." fa/
vm scp-to "$HERE/fa.sh" fa/fa.sh
vm ssh 'cd ~/fa && nohup ./fa.sh > raw/fa.log 2>&1 &'
for _ in $(seq 1 180); do
  vm ssh 'test -f ~/fa/raw/DONE' 2>/dev/null && break
  sleep 10
done
vm scp-from fa/raw/. "$RAW/"
python3 -I "$HERE/summarize.py" "$RAW" "$OUT"

How this page was made: the commands above were run by an AI agent on the test box on November 1, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.