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
I can now login with ‘isaac’ and ‘password123’ for the basic User page
or Authelia, which is an admin, but again, not much to see here
The user settings do not include much
For instance, in “Security” we can change passwords
And the Two Factor Auth page basically says nothing needs it
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
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.
So even though I was logged in as ‘authelia’ (aka “Administrator”), it came back with “User”
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
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
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
but the new one did
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
Authentik also lets me add MFA devices without much effort
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.