Idp with Authelia

Simple light self hosted and OS

Posted by Isaac on Thursday, September 17, 2026

We took a look at Authentik recently that provides a nice centralized authentication and authorization solution that is Open Source and self-hosted. But there are other options out there.

One that caught my attention is Authelia.

Setup

I plan to skip docker and just start with Kubernetes. Since I need to route traffic in, I’ll need a DNS record. I can create that easily in Azure DNS

$ az account set --subscription "Pay-As-You-Go" && az network dns record-set a add-record -g idjdnsrg -z tpk.pw -a 76.156.69.232 -n authelia
{
  "ARecords": [
    {
      "ipv4Address": "76.156.69.232"
    }
  ],
  "TTL": 3600,
  "etag": "d32fd44e-1b18-411c-83a5-722b1862bb18",
  "fqdn": "authelia.tpk.pw.",
  "id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/idjdnsrg/providers/Microsoft.Network/dnszones/tpk.pw/A/authelia",
  "name": "authelia",
  "provisioningState": "Succeeded",
  "resourceGroup": "idjdnsrg",
  "targetResource": {},
  "trafficManagementProfile": {},
  "type": "Microsoft.Network/dnszones/A"
}

I want to now bring in the helm repo

$ helm repo add authelia https://charts.authelia.com && helm repo update authelia

I’ll need a password to use. To do so, I need to create a crypto hash

$ docker run --rm authelia/authelia:4.39.24 authelia crypto hash generate pbkdf2 --password "password123"

Authelia, when installed with helm, want’s a bit more up front.

# authelia.helm.values.yaml
# Helm values for deploying Authelia on Kubernetes
# Repository: https://charts.authelia.com (chart: authelia/authelia)
# Host: https://authelia.tpk.pw

pod:
  kind: 'Deployment'
  replicas: 1
  extraVolumeMounts:
    - name: users-config
      mountPath: /config/users_database.yml
      subPath: users_database.yml
  extraVolumes:
    - name: users-config
      configMap:
        name: authelia-users

ingress:
  enabled: true
  className: 'nginx'
  annotations:
    cert-manager.io/cluster-issuer: azuredns-tpkpw
    ingress.kubernetes.io/ssl-redirect: 'true'
    kubernetes.io/tls-acme: 'true'
  tls:
    enabled: true
    secret: 'authelia-tls'

persistence:
  enabled: true
  storageClass: 'local-path'
  size: '1Gi'

configMap:
  session:
    cookies:
      - domain: 'tpk.pw'
        subdomain: 'authelia'
        default_redirection_url: 'https://authelia.tpk.pw'
  storage:
    local:
      enabled: true
      path: '/config/db.sqlite3'
  authentication_backend:
    file:
      enabled: true
      path: '/config/users_database.yml'
      watch: true
  access_control:
    default_policy: 'deny'
    rules:
      - domain: '*.tpk.pw'
        policy: 'one_factor'
  notifier:
    filesystem:
      enabled: true
      filename: '/config/notification.txt'
  identity_providers:
    oidc:
      enabled: true
      clients:
        - client_id: 'sample-python-app'
          client_name: 'Sample Python App'
          client_secret:
            value: '$plaintext$sample-app-secret-123'
          public: false
          authorization_policy: 'one_factor'
          redirect_uris:
            - 'http://localhost:5000/callback'
            - 'http://127.0.0.1:5000/callback'
          scopes:
            - 'openid'
            - 'profile'
            - 'email'
            - 'groups'
          response_types:
            - 'code'
          grant_types:
            - 'authorization_code'
          response_modes:
            - 'form_post'
            - 'query'

# Extra Kubernetes objects deployed alongside the chart
extraObjects:
  - apiVersion: v1
    kind: ConfigMap
    metadata:
      name: authelia-users
      labels:
        app.kubernetes.io/name: authelia
    data:
      users_database.yml: |
        # Authelia File-based User Database
        # Passwords hashed with PBKDF2-SHA512. Default password for both users is: password123
        users:
          authelia:
            displayname: "Authelia Administrator"
            password: "$pbkdf2-sha512$310000$fXqzpDuJfh.yBX73sy2tTw$7EnFNM0Z5FMGZZtbV15.7mjoHCo/emnwkbbEIf204.KTV4zsOfxZdIzT9QyOfrmuj42obATrq0y2bPz5DoqWUQ"
            email: "admin@tpk.pw"
            groups:
              - admins
              - dev
          isaac:
            displayname: "Isaac"
            password: "$pbkdf2-sha512$310000$fXqzpDuJfh.yBX73sy2tTw$7EnFNM0Z5FMGZZtbV15.7mjoHCo/emnwkbbEIf204.KTV4zsOfxZdIzT9QyOfrmuj42obATrq0y2bPz5DoqWUQ"
            email: "isaac@tpk.pw"
            groups:
              - admins
              - users

The passwords were generated with

authelia crypto hash generate pbkdf2 --password "sample-app-secret-123"

This should create host entries for me, but I get nervous by the lack of a standard Ingress helm chart block with host specified.

$ helm upgrade --install authelia authelia/authelia   -f authelia.helm.values.yaml
Release "authelia" does not exist. Installing it now.
NAME: authelia
LAST DEPLOYED: Tue Sep 15 07:19:53 2026
NAMESPACE: default
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
TEST SUITE: None
NOTES:
Thank you for installing the authelia-0.11.22 chart.

IMPORTANT: This chart automatically generated an encryption key for sensitive data in your database. Please ensure you backup this key.

Please report any chart issues at https://github.com/authelia/chartrepo/issues and any application issues at https://github.com/authelia/authelia/issues.

You can configure your ingress or proxy in the following ways:

The following example demonstrates configuration of the AuthRequest implementation for ingresses like ingress-nginx:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example
  namespace: example-namespace
  annotations:
    nginx.ingress.kubernetes.io/auth-url: http://authelia.default.svc.cluster.local/api/authz/auth-request
    nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Groups,Remote-Email

The following examples demonstrate configuration of the ExtAuthz implementation for ingresses like Istio and Envoy:

    At this stage no examples exist. Feel free to contribute.

    The URL for this implementation is: http://authelia.default.svc.cluster.local/api/authz/ext-authz

The following examples demonstrate configuration of the ForwardAuth implementation for ingresses like Traefik using a IngressRoute CRD manifest:

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: example
  namespace: example-namespace
spec:
  routes:
  - kind: Rule
    middlewares:
    - name: chain-authelia-auth
      namespace: default

The following examples demonstrate configuration of the ForwardAuth implementation for ingresses like Traefik using a standard Ingress manifest:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example
  namespace: example-namespace
  annotations:
    traefik.ingress.kubernetes.io/router.middlewares: default-chain-authelia-auth@kubernetescrd

The first issue I encountered when it started up was the main pod crashing:

$ kubectl logs authelia-6fb7b45449-xn5mr
time="2026-09-15T12:21:26Z" level=warning msg="Configuration: identity_providers: oidc: clients: client 'sample-python-app': option 'client_secret' is plaintext but for clients not using any endpoint authentication method 'client_secret_jwt' it should be a hashed value as plaintext values are deprecated with the exception of 'client_secret_jwt' and will be removed in the near future"
time="2026-09-15T12:21:26Z" level=error msg="Configuration: session: domain config #1 (domain 'tpk.pw'): option 'default_redirection_url' with value 'https://authelia.tpk.pw' is effectively equal to option 'authelia_url' with value 'https://authelia.tpk.pw' which is not permitted"
time="2026-09-15T12:21:26Z" level=error msg="Configuration: identity_providers: oidc: option `jwks` is required"
time="2026-09-15T12:21:26Z" level=fatal msg="Can't continue due to the errors loading the configuration" stack="github.com/spf13/cobra@v1.10.2/command.go:1000          (*Command).execute\ngithub.com/spf13/cobra@v1.10.2/command.go:1148          (*Command).ExecuteC\ngithub.com/spf13/cobra@v1.10.2/command.go:1071          (*Command).Execute\ngithub.com/authelia/authelia/v4/cmd/authelia/main.go:16 main\ninternal/runtime/atomic/types.go:194                    (*Uint32).Load\nruntime/asm_amd64.s:1264                                goexit"

Basically the auth redirect cannot be the same as the access URL.

Also, in a client secret, we did plaintext instead of encrypted, which I can switch with helm:

1 -            client_secret:
1 -              value: '$plaintext$my-insecure-secret'   # or a hashed secret
1 +            client_secret: '$pbkdf2-sha512$...'   # Hashed secret (generate with authelia crypto
       hash generate pbkdf2)

I also needed to add a private key to the IDP OIDC setup

identity_providers:
      oidc:
        enabled: true
        jwks:
          - key_id: 'default'
            algorithm: 'RS256'
            use: 'sig'
            key:
              value: |
                -----BEGIN PRIVATE KEY-----
                ...

Lastly, I removed the redirection URL.. so in total, the changes were:

$ diff values.old values.new
18,19c18
<         client_secret:
<           value: $plaintext$sample-app-secret-123
---
>         client_secret: $pbkdf2-sha512$310000$FsQwdUGUX/aHdBSKwD1IUg$M5zA6GS5Y5fj/edCCtTIgoq6N/nUgZr5Yo8Nls3zKKFYuFFsLeRz4CC5N93JVDQlEZqQfPV2CsrRxQKTbTAgFQ
36a36,69
>       jwks:
>       - algorithm: RS256
>         key:
>           value: |
>             -----BEGIN PRIVATE KEY-----
>             MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6++mSsCg9A0vE
>             9KIFp91LI+ZTQC7oCZmvO3KQTIQsDzp4GwzP4P1afX+6YEJLCB2g1WqaEV0Mvwtf
>             ewuNonW8VOhq54YO8z0DeuO8fsErOcVLAIE4TgAgyWwRQvpnrnhywtjY0hNigZMr
>             p9WVveRrEvT2x3uI8EFiIH15QnlsmN3nKGeKYmlplM9cySJcahhRJBj2C5HHzKEg
>             52iBe1aeL1hi5qZD9auwbJfr/lFsabjaTgKK9gEXdtyYAanSMu44DU/oOmw1pwc2
>             IrSCOpwOo5QwBZg9fCcK5rCDav9VUIV0W2PCfjUYjYUe2wESBOefr8PtgqFIO9OJ
>             1qaYQEGbAgMBAAECggEAIJKOo0uFju9ajSJScSrHXXYRAvKc2TYG7CQudl2l7qju
>             dgq8RiA68j8Hd5eaJMjypFhZHCKnM3e6SvU70334BYlC/ZB8ZXFQ8SDAuE7aZqXH
>             LSQW3pCT7CI6bZ1d0p7tg4xWyxp5XwEUepffmJi+SDrCqpSge//iW+4t/Wgrj9Oa
>             StTA0aY4wsUEg90AynUDkfqqOmmeZUocj9QkWeqX8h3+NDZYjRm9xsgwtxCRHISc
>             2CHAGRzbTzgo6Wo6k3++HrLoZjFxxXenF85LOX0Leqba/1bbfRzikkn9W+tetq3c
>             lBspFTO5904A/mkf26xyoCcj+0RfLEjYzJJd/WeOBQKBgQD+KLMdETTI+g/8qzaa
>             mJU6Ny4svSUutgl1s8liYkOk93NuSG/tyVpTiAHz9Xe0B5q7h2Glb2dv7272sYeO
>             +qO2tF8tvPZ6eZwlKv7fb/jGpE4ybQN5E6L5MpWffmVFH93mn+xHngQyMOkMXYl5
>             5CpXwVyLGZpst8UN8VLs+BWzZwKBgQC8VqWN9amTXODmRa4vvd8GX99WYq2/wn6X
>             Eo3+F8vq58w4cnbiBR9gtkUAnKgW5QWsPAERGwrLxSpfHB9bPNzM1cLwwZMq/tJr
>             NcrX3jHNK5WlEsLpyNiowarY/CBRooPNam6RSXJCnB7Dh2JcuXIPl204H5zaFMwA
>             neGcl5OzrQKBgQDsdJkPNe7R/DPbcr6+Xa6YFrZS0TZCmwF6C+YULi+Yzs8Jj0Lz
>             Cx2KEUMf4QOY7mo6hd2GuHqXXT7zLH9dujmNxYm3V9JIZ9OpkLLG1bmxtTM7HsjY
>             YDiDd1hUppc5FEiyQ57jklN9DpwC8RLx4CC0vCSJFSzicKZYLmhkJvqpiQKBgQC2
>             Yk42WATcgOAF/sp8zykv+h3EgRDzFz0RvVUmEBNYKxrYOvinTgCh3kCaJBqe+S/y
>             J7V8xCxDQm8S5Z/z8c98yTDbhwmmZFiOm+wP+ctOfXuP/MgmL2qomcuCDz6Y74El
>             poDmTzLIEHm2Ld/yHV+4e5K3+90gT21y13GI/Dx7jQKBgC3BMxuSmCN7tQGH2fsY
>             SR8d4UMJftj+kzDq35jIR55rEiZjhpNOmoCqKzdqiJiFNWUzmuHQZwTwfGrao2sX
>             R3oulVlThb66sVoRnlKJr2vIIaJZ502uqMeAl9n37AaOEM0dDoP3K469mT8Zf0w0
>             iKdMXSIEPapdcq3LyyqB9Ol/
>             -----END PRIVATE KEY-----
>         key_id: default
>         use: sig
43,44c76
<     - default_redirection_url: https://authelia.tpk.pw
<       domain: tpk.pw
---
>     - domain: tpk.pw

Now the login page works

/img/2026-09-authelia-01.png

I can now login with ‘isaac’ and ‘password123’ for the basic User page

/img/2026-09-authelia-02.png

or Authelia, which is an admin, but again, not much to see here

/img/2026-09-authelia-03.png

The user settings do not include much

/img/2026-09-authelia-04.png

For instance, in “Security” we can change passwords

/img/2026-09-authelia-05.png

And the Two Factor Auth page basically says nothing needs it

/img/2026-09-authelia-06.png

From a command line, we can get the very large “well-known” block

$ curl -s https://authelia.tpk.pw/.well-known/openid-configuration | jq .
{
  "issuer": "https://authelia.tpk.pw",
  "jwks_uri": "https://authelia.tpk.pw/jwks.json",
  "authorization_endpoint": "https://authelia.tpk.pw/api/oidc/authorization",
  "token_endpoint": "https://authelia.tpk.pw/api/oidc/token",
  "subject_types_supported": [
    "public",
    "pairwise"
  ],
  ...snip...
}

Sample App

Let’s fire up a sample app

sample-app/
├── Dockerfile              # Multi-stage Python 3.12 container
├── docker-compose.yml      # Docker compose configuration
├── requirements.txt        # Flask, Authlib, Requests, Dotenv, Gunicorn
├── .env.example            # Environment template
├── .env                    # Preconfigured local environment
├── app.py                  # Dual-mode Flask application (OIDC + Forward-Auth)
└── templates/
    ├── base.html           # Tailwind CSS base layout
    └── index.html          # Hello World UI with claims & header inspector

It’s a basic flask app that will pull in the OAuth settings from an .env file or env vars:

import json
import os
import secrets
from datetime import datetime, timezone
from urllib.parse import urlencode

from authlib.integrations.flask_client import OAuth
from dotenv import load_dotenv
from flask import (
    Flask,
    flash,
    jsonify,
    redirect,
    render_template,
    request,
    session,
    url_for,
)

# Load environment variables
load_dotenv()

app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32)

# Allow HTTP for local testing if INSECURE_TRANSPORT is enabled
if os.environ.get("INSECURE_TRANSPORT", "true").lower() in ("true", "1", "yes"):
    os.environ["AUTHLIB_INSECURE_TRANSPORT"] = "1"
    os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"

# --- Authelia Configuration ---
AUTHELIA_URL = os.environ.get("AUTHELIA_URL", "https://authelia.tpk.pw").rstrip("/")
CLIENT_ID = os.environ.get("AUTHELIA_CLIENT_ID", "sample-python-app")
CLIENT_SECRET = os.environ.get("AUTHELIA_CLIENT_SECRET", "sample-app-secret-123")
REDIRECT_URI = os.environ.get("AUTHELIA_REDIRECT_URI", "")
SCOPES = os.environ.get("AUTHELIA_SCOPES", "openid profile email groups")

# Standard OpenID Connect discovery endpoint for Authelia
DISCOVERY_URL = os.environ.get(
    "AUTHELIA_DISCOVERY_URL",
    f"{AUTHELIA_URL}/.well-known/openid-configuration",
)

oauth = OAuth(app)
oauth.register(
    name="authelia",
    client_id=CLIENT_ID,
    client_secret=CLIENT_SECRET,
    server_metadata_url=DISCOVERY_URL,
    client_kwargs={
        "scope": SCOPES,
        "code_challenge_method": "S256",  # RFC 7636 PKCE
    },
)


def is_configured() -> bool:
    """Check if Authelia client credentials are set."""
    return bool(CLIENT_ID and CLIENT_SECRET and CLIENT_ID != "your-authelia-client-id")


def get_forward_auth_headers():
    """
    Detect if request was proxied through Authelia Forward-Auth (Reverse Proxy).
    Authelia passes these headers downstream: Remote-User, Remote-Name, Remote-Email, Remote-Groups.
    """
    remote_user = request.headers.get("Remote-User")
    if remote_user:
        return {
            "user": remote_user,
            "name": request.headers.get("Remote-Name", remote_user),
            "email": request.headers.get("Remote-Email", ""),
            "groups": [g.strip() for g in request.headers.get("Remote-Groups", "").split(",") if g.strip()],
        }
    return None


@app.context_processor
def inject_globals():
    """Inject configuration state and current user into templates."""
    forward_auth = get_forward_auth_headers()
    return {
        "is_configured": is_configured(),
        "authelia_url": AUTHELIA_URL,
        "client_id": CLIENT_ID,
        "discovery_url": DISCOVERY_URL,
        "current_user": session.get("user"),
        "forward_auth_user": forward_auth,
    }


@app.route("/")
def index():
    """Home / Hello World landing page."""
    user = session.get("user")
    forward_auth = get_forward_auth_headers()
    return render_template("index.html", user=user, forward_auth=forward_auth)


@app.route("/login")
def login():
    """Initiates OpenID Connect Authorization Code Flow with PKCE."""
    if not is_configured():
        flash("Authelia Client ID or Secret is not configured. Please check your .env file!", "warning")
        return redirect(url_for("index"))

    redirect_uri = REDIRECT_URI or url_for("callback", _external=True)
    try:
        return oauth.authelia.authorize_redirect(redirect_uri)
    except Exception as exc:
        flash(f"Failed to initiate login with Authelia: {str(exc)}", "danger")
        return redirect(url_for("index"))


@app.route("/callback")
def callback():
    """OIDC Callback handler: exchanges authorization code for tokens and user claims."""
    error = request.args.get("error")
    if error:
        error_desc = request.args.get("error_description", "No description provided.")
        flash(f"Authentication error from Authelia: {error} - {error_desc}", "danger")
        return redirect(url_for("index"))

    try:
        token = oauth.authelia.authorize_access_token()
    except Exception as exc:
        flash(f"Failed to exchange token with Authelia: {str(exc)}", "danger")
        return redirect(url_for("index"))

    userinfo = token.get("userinfo")
    if not userinfo:
        try:
            resp = oauth.authelia.get("userinfo", token=token)
            userinfo = resp.json()
        except Exception:
            userinfo = {}

    expires_at = token.get("expires_at")
    expires_at_iso = None
    if expires_at:
        try:
            expires_at_iso = datetime.fromtimestamp(expires_at, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
        except Exception:
            expires_at_iso = str(expires_at)

    session["user"] = {
        "info": userinfo,
        "username": userinfo.get("preferred_username") or userinfo.get("sub", "User"),
        "name": userinfo.get("name") or userinfo.get("preferred_username", "User"),
        "email": userinfo.get("email", ""),
        "groups": userinfo.get("groups", []),
        "token_meta": {
            "token_type": token.get("token_type", "Bearer"),
            "scope": token.get("scope", SCOPES),
            "expires_in": token.get("expires_in"),
            "expires_at": expires_at,
            "expires_at_human": expires_at_iso,
            "has_refresh_token": bool(token.get("refresh_token")),
            "has_id_token": bool(token.get("id_token")),
        },
        "raw_claims": userinfo,
        "logged_in_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
    }

    flash(f"Successfully logged in via Authelia as {session['user']['name']}!", "success")
    return redirect(url_for("index"))


@app.route("/logout")
def logout():
    """Local logout: Clears the Flask session."""
    session.clear()
    flash("You have been logged out locally.", "info")
    return redirect(url_for("index"))


@app.route("/logout/authelia")
def logout_authelia():
    """Global logout: Clears local session and redirects to Authelia's logout endpoint."""
    session.clear()
    flash("You have been logged out.", "info")
    return redirect(f"{AUTHELIA_URL}/logout")


@app.route("/health")
def health():
    """Health check endpoint for container orchestration."""
    return jsonify({
        "status": "healthy",
        "configured": is_configured(),
        "authelia_url": AUTHELIA_URL,
        "discovery_url": DISCOVERY_URL,
    })


if __name__ == "__main__":
    port = int(os.environ.get("PORT", 5000))
    debug = os.environ.get("DEBUG", "true").lower() in ("true", "1", "yes")
    app.run(host="0.0.0.0", port=port, debug=debug)

My .env file has a callback (locally this would be localhost:5000) and a client ID and Secret which you saw we specified in the identity providers block in the helm chart already

$ cat .env
# Authelia Instance URL (must use https)
AUTHELIA_URL=https://authelia.tpk.pw

# OIDC Client Credentials (must match clients config in authelia.helm.values.yaml)
AUTHELIA_CLIENT_ID=sample-python-app
AUTHELIA_CLIENT_SECRET=sample-app-secret-123

# OIDC Discovery endpoint (Authelia serves standard discovery at root)
AUTHELIA_DISCOVERY_URL=https://authelia.tpk.pw/.well-known/openid-configuration

# Redirect URI (must match one of redirect_uris registered in Authelia)
AUTHELIA_REDIRECT_URI=http://localhost:5000/callback

# Requested Scopes
AUTHELIA_SCOPES=openid profile email groups

# Flask Session secret key
FLASK_SECRET_KEY=authelia-demo-secret-key-change-in-production

# Allow HTTP for local testing
INSECURE_TRANSPORT=true

# Server port
PORT=5000

It’s a pretty basic Dockerfile for flask

FROM python:3.12-slim

WORKDIR /app

# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application files
COPY . .

# Expose server port
EXPOSE 5000

ENV PORT=5000
ENV PYTHONUNBUFFERED=1

CMD ["python", "app.py"]

Let’s fire it up with docker compose up --build

isaac@isaac-G707:~/Workspaces/authelia/sample-app$ ls
Dockerfile  __pycache__  app.py  docker-compose.yml  requirements.txt  templates
isaac@isaac-G707:~/Workspaces/authelia/sample-app$ docker compose up --build
[+] Building 9.2s (12/12) FINISHED
 => [internal] load local bake definitions                                                         0.0s
 => => reading from stdin 594B                                                                     0.0s
 => [internal] load build definition from Dockerfile                                               0.0s
 => => transferring dockerfile: 307B                                                               0.0s
 => [internal] load metadata for docker.io/library/python:3.12-slim                                0.5s
 => [internal] load .dockerignore                                                                  0.0s
 => => transferring context: 120B                                                                  0.0s
 => [1/5] FROM docker.io/library/python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2  0.0s
 => => resolve docker.io/library/python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2  0.0s
 => [internal] load build context                                                                  0.0s
 => => transferring context: 22.30kB                                                               0.0s
 => CACHED [2/5] WORKDIR /app                                                                      0.0s
 => [3/5] COPY requirements.txt .                                                                  0.0s
 => [4/5] RUN pip install --no-cache-dir -r requirements.txt                                       6.5s
 => [5/5] COPY . .                                                                                 0.0s
 => exporting to image                                                                             2.1s
 => => exporting layers                                                                            1.4s
 => => exporting manifest sha256:05134e4e494f466e02c4850cc5eac39ee23fa6cd1106df3f0dfa776a9ad3f6d5  0.0s
 => => exporting config sha256:831b11934e3da7b27a3f7087cca299ce50d050f13c62f678840451c20723b52b    0.0s
 => => exporting attestation manifest sha256:f9263e1821b2af207b474e55d84be2578fbdd2fdf7c88fb79742  0.0s
 => => exporting manifest list sha256:cf93bb5d9d2e03a6a58aa3a575383d033faadf8c88af72d50317be0acd1  0.0s
 => => naming to docker.io/library/sample-app-authelia-sample-app:latest                           0.0s
 => => unpacking to docker.io/library/sample-app-authelia-sample-app:latest                        0.6s
 => resolving provenance for metadata file                                                         0.0s
[+] up 3/3
 ✔ Image sample-app-authelia-sample-app Built                                                       9.3s
 ✔ Network sample-app_default           Created                                                     0.0s
 ✔ Container authelia-sample-app        Created                                                     0.0s
Attaching to authelia-sample-app
authelia-sample-app  |  * Serving Flask app 'app'
authelia-sample-app  |  * Debug mode: on
authelia-sample-app  | WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
authelia-sample-app  |  * Running on all addresses (0.0.0.0)
authelia-sample-app  |  * Running on http://127.0.0.1:5000
authelia-sample-app  |  * Running on http://172.21.0.2:5000
authelia-sample-app  | Press CTRL+C to quit
authelia-sample-app  |  * Restarting with stat
authelia-sample-app  |  * Debugger is active!
authelia-sample-app  |  * Debugger PIN: 972-538-967

It looks a lot like our Authentik app

/img/2026-09-authelia-07.png

On sign in, it will ask if I want to let this app get at my creds as these are the asks of the OpenID Connect flow.

/img/2026-09-authelia-08.png

So even though I was logged in as ‘authelia’ (aka “Administrator”), it came back with “User”

/img/2026-09-authelia-09.png

Here we can see the full flow:

I noticed we were still seeing “User” and realized we neglected the claim policies block on the app setup in helm:

$ diff values.old2 values.new2
13a14,21
>       claims_policies:
>         default:
>           id_token:
>           - groups
>           - email
>           - email_verified
>           - preferred_username
>           - name
15a24
>         claims_policy: default

That also meant changing the app a bit (e.g. username instead of preferred_username):

$ cat app.py
import json
import os
import secrets
from datetime import datetime, timezone
from urllib.parse import urlencode

from authlib.integrations.flask_client import OAuth
from dotenv import load_dotenv
from flask import (
    Flask,
    flash,
    jsonify,
    redirect,
    render_template,
    request,
    session,
    url_for,
)

# Load environment variables
load_dotenv()

app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32)

# Allow HTTP for local testing if INSECURE_TRANSPORT is enabled
if os.environ.get("INSECURE_TRANSPORT", "true").lower() in ("true", "1", "yes"):
    os.environ["AUTHLIB_INSECURE_TRANSPORT"] = "1"
    os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"

# --- Authelia Configuration ---
AUTHELIA_URL = os.environ.get("AUTHELIA_URL", "https://authelia.tpk.pw").rstrip("/")
CLIENT_ID = os.environ.get("AUTHELIA_CLIENT_ID", "sample-python-app")
CLIENT_SECRET = os.environ.get("AUTHELIA_CLIENT_SECRET", "sample-app-secret-123")
REDIRECT_URI = os.environ.get("AUTHELIA_REDIRECT_URI", "")
SCOPES = os.environ.get("AUTHELIA_SCOPES", "openid profile email groups")

# Standard OpenID Connect discovery endpoint for Authelia
DISCOVERY_URL = os.environ.get(
    "AUTHELIA_DISCOVERY_URL",
    f"{AUTHELIA_URL}/.well-known/openid-configuration",
)

oauth = OAuth(app)
oauth.register(
    name="authelia",
    client_id=CLIENT_ID,
    client_secret=CLIENT_SECRET,
    server_metadata_url=DISCOVERY_URL,
    client_kwargs={
        "scope": SCOPES,
        "code_challenge_method": "S256",  # RFC 7636 PKCE
    },
)


def is_configured() -> bool:
    """Check if Authelia client credentials are set."""
    return bool(CLIENT_ID and CLIENT_SECRET and CLIENT_ID != "your-authelia-client-id")


def get_forward_auth_headers():
    """
    Detect if request was proxied through Authelia Forward-Auth (Reverse Proxy).
    Authelia passes these headers downstream: Remote-User, Remote-Name, Remote-Email, Remote-Groups.
    """
    remote_user = request.headers.get("Remote-User")
    if remote_user:
        return {
            "user": remote_user,
            "name": request.headers.get("Remote-Name", remote_user),
            "email": request.headers.get("Remote-Email", ""),
            "groups": [g.strip() for g in request.headers.get("Remote-Groups", "").split(",") if g.strip()],
        }
    return None


@app.context_processor
def inject_globals():
    """Inject configuration state and current user into templates."""
    forward_auth = get_forward_auth_headers()
    return {
        "is_configured": is_configured(),
        "authelia_url": AUTHELIA_URL,
        "client_id": CLIENT_ID,
        "discovery_url": DISCOVERY_URL,
        "current_user": session.get("user"),
        "forward_auth_user": forward_auth,
    }


@app.route("/")
def index():
    """Home / Hello World landing page."""
    user = session.get("user")
    forward_auth = get_forward_auth_headers()
    return render_template("index.html", user=user, forward_auth=forward_auth)


@app.route("/login")
def login():
    """Initiates OpenID Connect Authorization Code Flow with PKCE."""
    if not is_configured():
        flash("Authelia Client ID or Secret is not configured. Please check your .env file!", "warning")
        return redirect(url_for("index"))

    redirect_uri = REDIRECT_URI or url_for("callback", _external=True)
    try:
        return oauth.authelia.authorize_redirect(redirect_uri)
    except Exception as exc:
        flash(f"Failed to initiate login with Authelia: {str(exc)}", "danger")
        return redirect(url_for("index"))


@app.route("/callback")
def callback():
    """OIDC Callback handler: exchanges authorization code for tokens and user claims."""
    error = request.args.get("error")
    if error:
        error_desc = request.args.get("error_description", "No description provided.")
        flash(f"Authentication error from Authelia: {error} - {error_desc}", "danger")
        return redirect(url_for("index"))

    try:
        token = oauth.authelia.authorize_access_token()
    except Exception as exc:
        flash(f"Failed to exchange token with Authelia: {str(exc)}", "danger")
        return redirect(url_for("index"))

    # Start with claims from the parsed ID Token
    userinfo = dict(token.get("userinfo") or {})

    # OpenID Connect specifications expect full profile claims (name, preferred_username, email, groups)
    # to be fetched from the UserInfo endpoint using the Access Token.
    try:
        remote_userinfo = oauth.authelia.userinfo(token=token)
        if remote_userinfo:
            userinfo.update(remote_userinfo)
    except Exception as exc:
        app.logger.warning(f"Could not retrieve claims from UserInfo endpoint: {exc}")

    expires_at = token.get("expires_at")
    expires_at_iso = None
    if expires_at:
        try:
            expires_at_iso = datetime.fromtimestamp(expires_at, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
        except Exception:
            expires_at_iso = str(expires_at)

    username = (
        userinfo.get("preferred_username")
        or userinfo.get("username")
        or userinfo.get("nickname")
        or userinfo.get("sub", "User")
    )
    display_name = (
        userinfo.get("name")
        or userinfo.get("display_name")
        or userinfo.get("preferred_username")
        or username
    )

    session["user"] = {
        "info": userinfo,
        "username": username,
        "name": display_name,
        "email": userinfo.get("email", ""),
        "groups": userinfo.get("groups", []),
        "token_meta": {
            "token_type": token.get("token_type", "Bearer"),
            "scope": token.get("scope", SCOPES),
            "expires_in": token.get("expires_in"),
            "expires_at": expires_at,
            "expires_at_human": expires_at_iso,
            "has_refresh_token": bool(token.get("refresh_token")),
            "has_id_token": bool(token.get("id_token")),
        },
        "raw_claims": userinfo,
        "logged_in_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
    }

    flash(f"Successfully logged in via Authelia as {display_name}!", "success")
    return redirect(url_for("index"))


@app.route("/logout")
def logout():
    """Local logout: Clears the Flask session."""
    session.clear()
    flash("You have been logged out locally.", "info")
    return redirect(url_for("index"))


@app.route("/logout/authelia")
def logout_authelia():
    """Global logout: Clears local session and redirects to Authelia's logout endpoint."""
    session.clear()
    flash("You have been logged out.", "info")
    return redirect(f"{AUTHELIA_URL}/logout")


@app.route("/health")
def health():
    """Health check endpoint for container orchestration."""
    return jsonify({
        "status": "healthy",
        "configured": is_configured(),
        "authelia_url": AUTHELIA_URL,
        "discovery_url": DISCOVERY_URL,
    })


if __name__ == "__main__":
    port = int(os.environ.get("PORT", 5000))
    debug = os.environ.get("DEBUG", "true").lower() in ("true", "1", "yes")
    app.run(host="0.0.0.0", port=port, debug=debug)

Now logging in shows the proper name

/img/2026-09-authelia-11.png

We can also see the other details in the claims inspector pane above

{
  "amr": [
    "pwd",
    "kba"
  ],
  "at_hash": "jgEFqGPw2ICHofTcS9x5-A",
  "aud": [
    "sample-python-app"
  ],
  "auth_time": 1789480526,
  "azp": "sample-python-app",
  "email": "isaac@tpk.pw",
  "email_verified": true,
  "exp": 1789484129,
  "groups": [
    "admins",
    "users"
  ],
  "iat": 1789480529,
  "iss": "https://authelia.tpk.pw",
  "jti": "da2b454d-0d50-4516-9918-62147d395785",
  "name": "Isaac",
  "nonce": "YQdwuRtVzyAsjZAdoATo",
  "preferred_username": "isaac",
  "rat": 1789480524,
  "sub": "84756def-6714-4da4-b166-7e6fe70bc1cf",
  "updated_at": 1789480530
}

For reference, the final helm values looked like this:

configMap:
  access_control:
    default_policy: deny
    rules:
    - domain: '*.tpk.pw'
      policy: one_factor
  authentication_backend:
    file:
      enabled: true
      path: /config/users_database.yml
      watch: true
  identity_providers:
    oidc:
      claims_policies:
        default:
          id_token:
          - groups
          - email
          - email_verified
          - preferred_username
          - name
      clients:
      - authorization_policy: one_factor
        claims_policy: default
        client_id: sample-python-app
        client_name: Sample Python App
        client_secret: $pbkdf2-sha512$310000$FsQwdUGUX/aHdBSKwD1IUg$M5zA6GS5Y5fj/edCCtTIgoq6N/nUgZr5Yo8Nls3zKKFYuFFsLeRz4CC5N93JVDQlEZqQfPV2CsrRxQKTbTAgFQ
        grant_types:
        - authorization_code
        public: false
        redirect_uris:
        - http://localhost:5000/callback
        - http://127.0.0.1:5000/callback
        response_modes:
        - form_post
        - query
        response_types:
        - code
        scopes:
        - openid
        - profile
        - email
        - groups
      enabled: true
      jwks:
      - algorithm: RS256
        key:
          value: |
            -----BEGIN PRIVATE KEY-----
            MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6++mSsCg9A0vE
            9KIFp91LI+ZTQC7oCZmvO3KQTIQsDzp4GwzP4P1afX+6YEJLCB2g1WqaEV0Mvwtf
            ewuNonW8VOhq54YO8z0DeuO8fsErOcVLAIE4TgAgyWwRQvpnrnhywtjY0hNigZMr
            p9WVveRrEvT2x3uI8EFiIH15QnlsmN3nKGeKYmlplM9cySJcahhRJBj2C5HHzKEg
            52iBe1aeL1hi5qZD9auwbJfr/lFsabjaTgKK9gEXdtyYAanSMu44DU/oOmw1pwc2
            IrSCOpwOo5QwBZg9fCcK5rCDav9VUIV0W2PCfjUYjYUe2wESBOefr8PtgqFIO9OJ
            1qaYQEGbAgMBAAECggEAIJKOo0uFju9ajSJScSrHXXYRAvKc2TYG7CQudl2l7qju
            dgq8RiA68j8Hd5eaJMjypFhZHCKnM3e6SvU70334BYlC/ZB8ZXFQ8SDAuE7aZqXH
            LSQW3pCT7CI6bZ1d0p7tg4xWyxp5XwEUepffmJi+SDrCqpSge//iW+4t/Wgrj9Oa
            StTA0aY4wsUEg90AynUDkfqqOmmeZUocj9QkWeqX8h3+NDZYjRm9xsgwtxCRHISc
            2CHAGRzbTzgo6Wo6k3++HrLoZjFxxXenF85LOX0Leqba/1bbfRzikkn9W+tetq3c
            lBspFTO5904A/mkf26xyoCcj+0RfLEjYzJJd/WeOBQKBgQD+KLMdETTI+g/8qzaa
            mJU6Ny4svSUutgl1s8liYkOk93NuSG/tyVpTiAHz9Xe0B5q7h2Glb2dv7272sYeO
            +qO2tF8tvPZ6eZwlKv7fb/jGpE4ybQN5E6L5MpWffmVFH93mn+xHngQyMOkMXYl5
            5CpXwVyLGZpst8UN8VLs+BWzZwKBgQC8VqWN9amTXODmRa4vvd8GX99WYq2/wn6X
            Eo3+F8vq58w4cnbiBR9gtkUAnKgW5QWsPAERGwrLxSpfHB9bPNzM1cLwwZMq/tJr
            NcrX3jHNK5WlEsLpyNiowarY/CBRooPNam6RSXJCnB7Dh2JcuXIPl204H5zaFMwA
            neGcl5OzrQKBgQDsdJkPNe7R/DPbcr6+Xa6YFrZS0TZCmwF6C+YULi+Yzs8Jj0Lz
            Cx2KEUMf4QOY7mo6hd2GuHqXXT7zLH9dujmNxYm3V9JIZ9OpkLLG1bmxtTM7HsjY
            YDiDd1hUppc5FEiyQ57jklN9DpwC8RLx4CC0vCSJFSzicKZYLmhkJvqpiQKBgQC2
            Yk42WATcgOAF/sp8zykv+h3EgRDzFz0RvVUmEBNYKxrYOvinTgCh3kCaJBqe+S/y
            J7V8xCxDQm8S5Z/z8c98yTDbhwmmZFiOm+wP+ctOfXuP/MgmL2qomcuCDz6Y74El
            poDmTzLIEHm2Ld/yHV+4e5K3+90gT21y13GI/Dx7jQKBgC3BMxuSmCN7tQGH2fsY
            SR8d4UMJftj+kzDq35jIR55rEiZjhpNOmoCqKzdqiJiFNWUzmuHQZwTwfGrao2sX
            R3oulVlThb66sVoRnlKJr2vIIaJZ502uqMeAl9n37AaOEM0dDoP3K469mT8Zf0w0
            iKdMXSIEPapdcq3LyyqB9Ol/
            -----END PRIVATE KEY-----
        key_id: default
        use: sig
  notifier:
    filesystem:
      enabled: true
      filename: /config/notification.txt
  session:
    cookies:
    - domain: tpk.pw
      subdomain: authelia
  storage:
    local:
      enabled: true
      path: /config/db.sqlite3
extraObjects:
- apiVersion: v1
  data:
    users_database.yml: |
      # Authelia File-based User Database
      # Passwords hashed with PBKDF2-SHA512. Default password for both users is: password123
      users:
        authelia:
          displayname: "Authelia Administrator"
          password: "$pbkdf2-sha512$310000$fXqzpDuJfh.yBX73sy2tTw$7EnFNM0Z5FMGZZtbV15.7mjoHCo/emnwkbbEIf204.KTV4zsOfxZdIzT9QyOfrmuj42obATrq0y2bPz5DoqWUQ"
          email: "admin@tpk.pw"
          groups:
            - admins
            - dev
        isaac:
          displayname: "Isaac"
          password: "$pbkdf2-sha512$310000$fXqzpDuJfh.yBX73sy2tTw$7EnFNM0Z5FMGZZtbV15.7mjoHCo/emnwkbbEIf204.KTV4zsOfxZdIzT9QyOfrmuj42obATrq0y2bPz5DoqWUQ"
          email: "isaac@tpk.pw"
          groups:
            - admins
            - users
  kind: ConfigMap
  metadata:
    labels:
      app.kubernetes.io/name: authelia
    name: authelia-users
ingress:
  annotations:
    cert-manager.io/cluster-issuer: azuredns-tpkpw
    ingress.kubernetes.io/ssl-redirect: "true"
    kubernetes.io/tls-acme: "true"
  className: nginx
  enabled: true
  tls:
    enabled: true
    secret: authelia-tls
persistence:
  enabled: true
  size: 1Gi
  storageClass: local-path
pod:
  extraVolumeMounts:
  - mountPath: /config/users_database.yml
    name: users-config
    subPath: users_database.yml
  extraVolumes:
  - configMap:
      name: authelia-users
    name: users-config
  kind: Deployment
  replicas: 1

Password Rotation

Since I shared my accounts above, let’s take one last moment to show how to rotate passwords (leaving admin with password123 might be a foolish idea).

(venv) isaac@isaac-G707:~/Workspaces/authelia$ docker run --rm authelia/authelia:4.39.24 authelia crypto hash generate pbkdf2 --password "NotMyRealPassword!"
Digest: $pbkdf2-sha512$310000$NcqlyamqkmEItGhf2ARDDg$JeSDVCggzctmXSBPzxWl0JMTVxEBXVulZPrLTq1h4nO5Wi90ov4Mx0/sY/k5ZFG0L4.PDwZ.r3y4EnOZoS6MEg

I’ll now edit the yaml file and update the password blocks (which are password123 now)

(venv) isaac@isaac-G707:~/Workspaces/authelia$ helm get values authelia -o yaml > myvalues.yaml
(venv) isaac@isaac-G707:~/Workspaces/authelia$ helm get values authelia -o yaml > myvalues.yaml.old
(venv) isaac@isaac-G707:~/Workspaces/authelia$ vi myvalues.yaml

/img/2026-09-authelia-20.png

then use helm upgrade to apply the changes

$ helm upgrade --install authelia -f ./myvalues.yaml authelia/authelia
Release "authelia" has been upgraded. Happy Helming!
NAME: authelia
LAST DEPLOYED: Wed Sep 16 06:31:23 2026
NAMESPACE: default
STATUS: deployed
REVISION: 4
DESCRIPTION: Upgrade complete
TEST SUITE: None
NOTES:
Thank you for installing the authelia-0.11.22 chart.
...snip...

I noticed that because the image didn’t change, k8s did not rotate the pod - so it likely would have old cached values.

Thus I manually rotated those pods

(venv) isaac@isaac-G707:~/Workspaces/authelia$ kubectl get po | grep auth
authelia-566f4cbd65-gcbgg                            1/1     Running            0                  21h
authentik-postgresql-0                               1/1     Running            0                  14d
authentik-server-565b7bd76b-tvbsd                    1/1     Running            20 (12d ago)       14d
authentik-worker-94f68dc45-cdjb5                     1/1     Running            2 (12d ago)        14d
(venv) isaac@isaac-G707:~/Workspaces/authelia$
(venv) isaac@isaac-G707:~/Workspaces/authelia$ kubectl delete po authelia-566f4cbd65-gcbgg authentik-server-565b7bd76b-tvbsd
pod "authelia-566f4cbd65-gcbgg" deleted from default namespace
pod "authentik-server-565b7bd76b-tvbsd" deleted from default namespace
(venv) isaac@isaac-G707:~/Workspaces/authelia$ kubectl get po | grep auth
authelia-566f4cbd65-8ztzb                            1/1     Running             0                  39s
authentik-postgresql-0                               1/1     Running             0                  14d
authentik-server-565b7bd76b-q6tjl                    0/1     ContainerCreating   0                  39s
authentik-worker-94f68dc45-cdjb5                     1/1     Running             2 (12d ago)        14d
(venv) isaac@isaac-G707:~/Workspaces/authelia$ kubectl get po | grep auth
authelia-566f4cbd65-8ztzb                            1/1     Running            0                  50s
authentik-postgresql-0                               1/1     Running            0                  14d
authentik-server-565b7bd76b-q6tjl                    0/1     Running            0                  50s
authentik-worker-94f68dc45-cdjb5                     1/1     Running            2 (12d ago)        14d
(venv) isaac@isaac-G707:~/Workspaces/authelia$ kubectl get po | grep auth
authelia-566f4cbd65-8ztzb                            1/1     Running            0                  74s
authentik-postgresql-0                               1/1     Running            0                  14d
authentik-server-565b7bd76b-q6tjl                    1/1     Running            0                  74s
authentik-worker-94f68dc45-cdjb5                     1/1     Running            2 (12d ago)        14d

I verified the old password no longer worked

/img/2026-09-authelia-21.png

but the new one did

/img/2026-09-authelia-22.png

Summary

We spun up Authelia into Kubernetes with relative ease. It was simple to add some users and a sample app that could use it. Compared to Authentik, everything is just driven by the helm chart which for simple systems is probably ideal.

The user experience is not quite as polished. For instance, for users there is no ’landing page’ as we have with Autehntik

/img/2026-09-authelia-12.png

Authentik also lets me add MFA devices without much effort

/img/2026-09-authelia-13.png

However, Authentik also has paywall features including some integrations. This is a bit spooky to me only in that I’ve seen those paywalls creep closer to core functionality in apps before (MinIO comes to mind).

Whereas, Authelia is open source and free - there isn’t a “pro” version they want you to adopt.

I have put the source code for the app and helm chart on a public Github page: https://github.com/idjohnson/authelia-sample-app. Use however you see fit.

Authelia is an interesting extra simple option I plan to keep in mind for simple auth needs and projects.