A lil' Go web app that acts as a Traefik forwardAuth middleware,
providing three layers of access control:
- IP allowlist — requests from listed IPs (or CIDR ranges) are allowed immediately, no login required.
- Bearer token auth — requests with a valid
Authorization: Bearer <token>header are authenticated without requiring a login page. Tokens are stored in a plain text file, one per line. - Credential auth — everyone else is redirected to a login page. Credentials are stored as bcrypt hashes in a plain text file.
go build -o lilath .
go build -o lilath-adduser ./cmd/adduser./lilath-adduser alice # prompts for password, writes to users.txt
./lilath-adduser -list # show all usernames
./lilath-adduser -delete bob # remove a user
./lilath-adduser -f /etc/lilath/users.txt alice # custom file pathdocker build -t lilath .docker run -d \
--name lilath \
-p 8080:8080 \
-v $(pwd)/data:/data \
lilathPlace your config.yaml and users.txt inside the mounted /data directory.
The container runs as a non-root user (uid 1000).
Configuration can be provided via a YAML file, environment variables, or a combination of both. Environment variables always take precedence over the config file.
cp config.example.yaml config.yaml
$EDITOR config.yamlEvery config option has a corresponding LILATH_* environment variable:
| Environment variable | Default | Description |
|---|---|---|
LILATH_LISTEN_ADDR |
:8080 |
Address/port to bind |
LILATH_CREDENTIALS_FILE |
users.txt |
Path to credentials file |
LILATH_IP_ALLOWLIST |
(empty) | Comma-separated IPs/CIDRs that skip auth |
LILATH_SESSION_SECRET |
(empty) | Optional session signing secret |
LILATH_SESSION_TTL_MINUTES |
60 |
Session lifetime in minutes |
LILATH_COOKIE_NAME |
lilath_session |
Session cookie name |
LILATH_BASE_DOMAIN |
(empty) | Optional base domain for login/cookie sharing across subdomains |
LILATH_COOKIE_SECURE |
true |
Set to false for plain HTTP testing |
LILATH_TRUST_FORWARDED_FOR |
true |
Read client IP from X-Forwarded-For |
LILATH_LOGIN_TEMPLATE |
(empty) | Path to a custom HTML login template |
LILATH_TOKENS_FILE |
(empty) | Path to a Bearer tokens file (one token per line) |
LILATH_DEFAULT_USERS |
(empty) | Comma-separated usernames allowed by default; empty allows all |
LILATH_USERS_HEADER |
X-Lilath-Users |
Header carrying per-service allowed usernames |
LILATH_RATE_LIMIT_REQUESTS |
300 |
Max GET /auth requests per IP per window (0 disables) |
LILATH_RATE_LIMIT_LOGIN |
10 |
Max POST /login attempts per IP per window (0 disables) |
LILATH_RATE_LIMIT_WINDOW |
60 |
Rate-limit window size in seconds |
LILATH_RATE_LIMIT_ALLOWLIST |
(empty) | Comma-separated IPs/CIDRs exempt from rate limiting |
Boolean variables accept true/1/yes/on and false/0/no/off.
LILATH_IP_ALLOWLIST accepts a comma-separated list (e.g. 127.0.0.1,10.0.0.0/8).
LILATH_RATE_LIMIT_ALLOWLIST also accepts a comma-separated list.
LILATH_DEFAULT_USERS accepts a comma-separated list of usernames (e.g. alice,bob).
When LILATH_BASE_DOMAIN is set (for example example.com), unauthenticated
requests are redirected to that domain's /login endpoint and session cookies
are written with domain .example.com so they are sent to subdomains.
./lilath -config config.yamlReload credentials without restarting:
kill -HUP <pid># traefik dynamic config
http:
middlewares:
lilath-auth:
forwardAuth:
address: "http://lilath:8080/auth"
trustForwardHeader: true
authRequestHeaders:
- "Authorization"
- "X-Lilath-Users"
authResponseHeaders:
- "X-Auth-User"http:
routers:
my-app:
rule: "Host(`app.example.com`)"
middlewares:
- lilath-auth
service: my-app-serviceThe example below uses environment variables so no config file mount is needed. The only required volume is the credentials file.
services:
traefik:
image: traefik:v3
command:
- --providers.docker=true
- --entrypoints.web.address=:80
ports:
- "80:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
lilath:
image: lilath # build your own
environment:
LILATH_CREDENTIALS_FILE: /data/users.txt
LILATH_COOKIE_SECURE: "true"
LILATH_TRUST_FORWARDED_FOR: "true"
LILATH_SESSION_TTL_MINUTES: "60"
# LILATH_IP_ALLOWLIST: "10.0.0.0/8,192.168.0.0/16"
# LILATH_SESSION_SECRET: "change-me"
volumes:
- ./users.txt:/data/users.txt
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
labels:
- "traefik.enable=true"
- "traefik.http.routers.lilath-login.rule=PathPrefix(`/login`) || PathPrefix(`/logout`)"
- "traefik.http.routers.lilath-login.entrypoints=web"
my-app:
image: my-app
labels:
- "traefik.enable=true"
- "traefik.http.routers.my-app.rule=Host(`app.example.com`)"
- "traefik.http.routers.my-app.middlewares=lilath-auth@docker"
- "traefik.http.middlewares.lilath-auth.forwardauth.address=http://lilath:8080/auth"
- "traefik.http.middlewares.lilath-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.lilath-auth.forwardauth.authRequestHeaders=Authorization,X-Lilath-Users"| Method | Path | Description |
|---|---|---|
GET |
/healthz |
Healthcheck endpoint — returns 200 when alive |
GET |
/auth |
forwardAuth endpoint — returns 200 or 302 |
GET |
/login |
Login page |
POST |
/login |
Submit credentials |
GET/POST |
/logout |
Invalidate session |
lilath applies per-IP fixed-window rate limiting by default:
GET /auth:300requests per60seconds per IPPOST /login:10attempts per60seconds per IP
When a limit is exceeded, lilath responds with 429 Too Many Requests.
Set these keys in config.yaml:
rate_limit_requests: 300
rate_limit_login_requests: 10
rate_limit_window_seconds: 60
rate_limit_allowlist:
- "10.0.0.0/8"
- "192.168.0.0/16"rate_limit_requests: maxGET /authrequests per IP per window (0disables)rate_limit_login_requests: maxPOST /loginattempts per IP per window (0disables)rate_limit_window_seconds: window size in secondsrate_limit_allowlist: IPs/CIDRs exempt from all rate limiting
Notes:
- IPs already listed in
ip_allowlistbypass auth and rate limiting. rate_limit_allowlistis useful for internal monitors, health checks, or trusted networks.
Rate limiting can also be configured with environment variables:
LILATH_RATE_LIMIT_REQUESTS=300
LILATH_RATE_LIMIT_LOGIN=10
LILATH_RATE_LIMIT_WINDOW=60
LILATH_RATE_LIMIT_ALLOWLIST=10.0.0.0/8,192.168.0.0/16Environment variables override values from config.yaml.
As an alternative to the web login page, lilath can authenticate requests that
carry an Authorization: Bearer <token> header. This is useful for API clients,
CI pipelines, or other automated tools that cannot interact with a login form.
Create a plain text file with one token per line. Lines beginning with # and
blank lines are ignored.
# tokens.txt — one Bearer token per line
ci-pipeline-token-abc123
monitoring-token-xyz789
Point lilath at the file via the tokens_file config key or the
LILATH_TOKENS_FILE environment variable:
tokens_file: "/data/tokens.txt"Tokens are reloaded on SIGHUP (same as credentials), so you can add or revoke
tokens without restarting the server:
kill -HUP <pid>
# or, for Docker:
docker kill --signal=HUP lilathPass the Authorization header through to the forwardAuth endpoint by adding
it to authRequestHeaders in your Traefik middleware configuration:
http:
middlewares:
lilath-auth:
forwardAuth:
address: "http://lilath:8080/auth"
trustForwardHeader: true
authRequestHeaders:
- "Authorization"
- "X-Lilath-Users"
authResponseHeaders:
- "X-Auth-User"By default every authenticated user (or token) can reach every service. You can tighten this so that only specific users are permitted on each service — without running separate lilath instances.
- Set
default_usersin your config (orLILATH_DEFAULT_USERS) to the list of usernames that should be allowed on services with no explicit override. Leave it empty to allow all authenticated users (the backward-compatible default). - Add
X-Lilath-Usersto theauthRequestHeaderslist of thelilath-authforwardAuth middleware so Traefik forwards it to lilath. - On any service where you want a different set of users, attach a Traefik
headersmiddleware that setsX-Lilath-Usersto a comma-separated list of allowed usernames. Use*to allow every authenticated user on that service.
Token authentication is never restricted by user lists — any valid bearer token
is allowed on every service regardless of default_users or X-Lilath-Users.
Suppose alice and bob are both in users.txt. You want:
- Most services — only
alice(viadefault_users) - Bob's service — only
bob - Shared service — both users
# config.yaml
default_users:
- alice# docker-compose.yml (labels on the Traefik / lilath service)
- "traefik.http.middlewares.lilath-auth.forwardauth.address=http://lilath:8080/auth"
- "traefik.http.middlewares.lilath-auth.forwardauth.authRequestHeaders=Authorization,X-Lilath-Users"
# bob-only service: inject X-Lilath-Users=bob before the auth check
- "traefik.http.middlewares.bob-only.headers.customRequestHeaders.X-Lilath-Users=bob"
- "traefik.http.routers.bob-service.middlewares=bob-only,lilath-auth"
# shared service: wildcard overrides default_users, allows everyone
- "traefik.http.middlewares.all-users.headers.customRequestHeaders.X-Lilath-Users=*"
- "traefik.http.routers.shared-service.middlewares=all-users,lilath-auth"
# most services: no extra middleware, default_users applies (alice only)
- "traefik.http.routers.alice-service.middlewares=lilath-auth"Middleware order matters. The
headersmiddleware that injectsX-Lilath-Usersmust appear beforelilath-authin the middleware chain so that Traefik adds the header before forwarding the auth request.
| Value | Meaning |
|---|---|
| (absent) | Fall back to default_users; if that is also empty, allow all |
alice,bob |
Only alice and bob are allowed |
* |
All authenticated users are allowed |
# Default allowed usernames when no per-service header is present.
# Empty (the default) permits all authenticated users.
default_users:
- alice
# Header name carrying the per-service user list.
# Defaults to "X-Lilath-Users". Change only if that name conflicts with
# something else in your stack.
# users_header: "X-Lilath-Users"# comments are allowed
alice:$2a$10$...bcrypt...
bob:$2a$10$...bcrypt...
Use lilath-adduser to manage entries safely. You can also generate a hash
manually:
htpasswd -bnBC 10 "" "mypassword" | tr -d ':\n' | sed 's/$2y/$2a/'- Set
cookie_secure: true(the default) so the session cookie is only sent over HTTPS. - Set
trust_forwarded_for: falseif lilath is exposed directly to untrusted networks. - The session store is in-memory; sessions are lost on restart.
The built-in login page can be replaced with your own HTML template without
recompiling. Set login_template in the config file (or the
LILATH_LOGIN_TEMPLATE environment variable) to the path of a Go
html/template file.
The template receives a single data value with two fields:
| Field | Type | Description |
|---|---|---|
.RedirectURL |
string |
The URL the user will be sent to after login |
.Error |
string |
Non-empty when credentials were rejected |
Minimal example template:
<!DOCTYPE html>
<html>
<body>
{{if .Error}}<p style="color:red">{{.Error}}</p>{{end}}
<form method="POST" action="/login">
<input type="hidden" name="rd" value="{{.RedirectURL}}">
<input type="text" name="username" placeholder="Username" autocomplete="username">
<input type="password" name="password" placeholder="Password" autocomplete="current-password">
<button type="submit">Sign in</button>
</form>
</body>
</html>Bind-mount your local template file into the container and point
LILATH_LOGIN_TEMPLATE at the in-container path:
services:
lilath:
image: lilath
environment:
LILATH_CREDENTIALS_FILE: /data/users.txt
LILATH_COOKIE_SECURE: "true"
LILATH_TRUST_FORWARDED_FOR: "true"
LILATH_LOGIN_TEMPLATE: /data/login.html
volumes:
- ./users.txt:/data/users.txt
- ./login.html:/data/login.html:roThe :ro flag makes the bind mount read-only inside the container.
Changes to login.html on the host take effect the next time the container
is restarted (the template is read once at startup).