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.

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
| Request | Authelia 4.39.28 behind Caddy forward_auth | authentik 2026.8.5 (embedded outpost) behind Caddy forward_auth |
|---|---|---|
| GET protected page, no cookie | 302, Location auth.lab.internal/ | 302, Location authentik.lab.internal/application/o/authorize/ |
| Same with Accept: application/json | 401 | 302 |
| Forged session cookie | 302 | 302 |
| Login by script | POST /api/firstfactor with the right password: 200; with a wrong password: 401 | flow 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 session | 200, backend saw Remote-User: lab | 200 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
| Stack | Container start to first 200 from the auth service (one run each) | RAM idle, MiB, auth service containers only | RAM idle, MiB, sum of all containers | RAM during 600-request burst, MiB | Idle RAM per container, MiB | Authenticated request, ms, median of 3 batches of 50 (range) | Burst: 600 requests, 8 parallel |
|---|---|---|---|---|---|---|---|
| Authelia 4.39.28 | 1.82 s | 60.2 | 74.6 | 212.7 (max 213.0) | fa-caddy-1 13; fa-authelia-1 60; fa-whoami-1 2 | 4.6 (4.4 to 5.3) | 600/600 OK in 1.88 s |
| authentik 2026.8.5 | 110.53 s | 822.6 | 892.4 | 936.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 12 | 6.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.
| Stack | Version | RAM at idle | CPU at idle | Images on disk | Ready after |
|---|---|---|---|---|---|
| Authelia | 4.39.28 | 0.02% | 109.9 MB | 2.2 s | |
| authentik | 2026.8.5 | 1.58% | 2356.7 MB | 100.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.
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.txtShow 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.
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
}
}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:
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/DONEWhat 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.internaland the portal URL washttps. 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
--resolveand 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
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.