Brings preauth from 18/34 to 30/34 conformance (auth-gateway profile). The
remaining four checks all depend on files this branch cannot change (see below).
PHP toolchain (§1)
- require.php >=8.4 -> ^8.5, and pin config.platform to 8.5.0. The old
constraint also permitted PHP 9, which is not a promise we can keep.
composer.lock regenerated with --lock: content-hash + platform-overrides
only, zero dependency version movement.
- friendsofphp/php-cs-fixer * -> ^3.95. A wildcard meant CI was not
reproducible.
PHPStan (§2.2)
- vendor the shared phpstan.neon.dist (level 6) + a generated baseline.
187 errors are captured rather than fixed; the baseline should only shrink
from here.
- add phpstan/phpstan:^2.1 to require-dev.
Code style (§8.2)
- vendor the shared .php-cs-fixer.dist.php (@Symfony + @Symfony:risky +
declare_strict_types) and apply it: 59 of 67 files reformatted.
- Verified this is a formatting change, not a behaviour change: all 313 tests
pass after the reformat, all in_array() calls already passed strict=true,
and the remaining edits are @Symfony:risky idiom (yoda conditions, \count(),
self:: over the class name).
Repository layout (§4.4)
- docs/{Caddyfile,compose.yaml,example.env} -> docs/examples/, with
example.env becoming the conventional .env.example. This is the layout
GUIDING-LIGHT already cites preauth as doing correctly — it just needed
renaming.
- update the four readme.md references and a stale compose.yaml comment.
- docs/v1.1-plan.md references are left alone deliberately: it is a historical
plan recording what was done at the time, not live documentation.
Licence and security policy (§7)
- add LICENSE (uniform MIT, matching composer.json).
- add SECURITY.md describing the actual threat model: per-request
allow/intercept, no caching of the login flow, app-set security headers,
TOTP, and the fact that REMOTE_USER is trusted input.
Mobile accessibility (§3.3a)
- templates/base.html.twig: drop maximum-scale=1 and add viewport-fit=cover.
preauth was the one app already past the font-size precondition (controls
render at 21.6px = 0.9em x 24px), so removing the lock is safe here and
restores pinch-zoom for Android users.
Conformance tooling (§8.2)
- vendor .ci/conformance.sh and .ci/css-control-size.py so the check runs
from a checkout rather than fetching from the LAN-only private/ci.
- .editorconfig synced from the version that keeps the Caddyfile tab rule.
Not included (blocked by the .gitea/workflows pre-receive hook):
- ci-composer-audit, ci-composer-validate, ci-reusable-workflows.
Workflow files may only change via a trusted ref, so the caller files are
staged but not committed.
Also not included: dockerfile-nonroot (§6.4). Adding USER to an image with
VOLUME [/config, /data] changes volume ownership and needs an actual container
build/run to verify, so it goes in its own change.
Preauth
A lightweight TOTP authentication gateway for self-hosted web services.
Preauth sits between your reverse proxy (Caddy) and your web service, requiring a TOTP code before traffic ever reaches the protected application. It is not a replacement for your service's own authentication — it's a gate that prevents outsiders from even seeing what service is running.
For when you want a belt and suspenders.
Features
- TOTP authentication — Time-based one-time passwords (compatible with Google Authenticator, Authy, 1Password, etc.)
- Backup codes — Single-use backup codes for when TOTP devices are lost
- Caddy native — Designed for Caddy's
forward_authdirective - Docker-first — Single container, persistent volumes, no database
- Rate limiting — Per-IP burst and sustained limits (cannot be disabled)
- Public rate-limited access — Optional, allow unauthenticated access to specific paths with separate rate limiting (e.g., public Gitea repos)
- Central auth — Optional subdomain-based SSO across multiple services
- IP-based bypass — Optional, for services that don't handle cookies
- Customizable — Colors, labels, messages, and error text via env vars
- Teapot mode — Respond with
418 I'm a Teapotwhen rate-limited (because it's more fun than429 Too Many Requests) - Cookie security —
__Host-prefixed cookies withSameSite=Strict,Secure, andHttpOnly - Nonce system — Single-use nonces prevent replay and CSRF attacks
- Dual-layer cache — APCu for speed, file-based persistence for restarts
Quick Start
1. Pull the Docker image
docker pull digitaladapt/preauth:latest
2. Create your environment file
# Generate a TOTP secret to get started
openssl rand -base64 30
Create a .env file (see docs/examples/.env.example for all options):
APP_SECRET=your-random-secret-here
TOTP_URI=otpauth://totp/Preauth?secret=YOUR_SECRET
COOKIE_TTL=2592000
If
TOTP_URIis left blank, the app will generate one on first run and print it to the container logs. Copy it to your.envfile.
3. Start the container
docker compose up -d
See docs/examples/compose.yaml for an example Docker Compose file.
4. Configure Caddy
service.example.com {
forward_auth preauth {
uri {uri}
copy_headers Remote-User
# keep the login flow out of browser/proxy caches
header_down Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0"
header_down Pragma "no-cache"
header_down Expires "0"
header_down Surrogate-Control "no-store"
header_down Vary "*"
}
reverse_proxy your-service:80
}
See docs/examples/Caddyfile for more examples, including path-specific protection
and central auth subdomain configuration. The header_down lines above are
optional — preauth already sends these headers itself — but they guarantee
at the edge that no part of the login flow is ever cached. (2xx auth
responses are consumed by forward_auth and never reach the browser, so
your service's own cache headers are unaffected.)
5. Generate backup codes (optional)
docker exec -t preauth bin/console app:generate-backup-codes [count=10]
Requirements
- Docker — Preauth runs as a Docker container
- Caddy — As your reverse proxy (uses
forward_authdirective) - A web service — The application you want to protect
Other reverse proxies with similar forward_auth / auth_request
capabilities may work, but only Caddy is officially supported.
Configuration
All configuration is via environment variables. See docs/examples/.env.example
for the complete reference.
Main Options
| Variable | Default | Description |
|---|---|---|
TOTP_URI |
(empty) | TOTP provisioning URI. If blank, one is generated on first run. |
COOKIE_TTL |
2592000 |
Session duration in seconds (default: 30 days). |
SUBDOMAIN_REDIRECT |
0 |
Enable central auth across subdomains (boolean). |
AUTH_SUBDOMAIN |
(empty) | Hostname for central auth (e.g., auth.example.com). |
Extra Options
| Variable | Default | Description |
|---|---|---|
IP_TTL |
0 |
Seconds to allow all traffic from an IP after login (0 = disabled). |
TEAPOT |
1 |
Respond with 418 instead of 429 when rate-limited (boolean). |
MAX_REQUESTS |
500 |
Restart each FrankenPHP worker thread after this many requests to contain memory growth (0 = unlimited). Maps to the Caddyfile max_requests directive. |
Remote-User Header
The Remote-User header sent to backends on successful auth is configurable:
| Variable | Default | Description |
|---|---|---|
REMOTE_USER |
session |
Mode: session, static, mapped, or none. |
REMOTE_USER_STATIC |
authenticated |
Value sent when mode is static. |
REMOTE_USER_MAP |
(empty) | Comma-separated map for mapped mode (e.g. alice:admin,bob:user). |
session(default): Sends the session id. Backward-compatible.static: Sends a fixed string for all authenticated requests.mapped: Looks up the session id in the map; falls back to session id if not found.none: Omits the header entirely (Caddy still accepts based on status code).
Rate Limiting
Rate limiting cannot be disabled. It uses a compound sliding window:
| Variable | Default | Description |
|---|---|---|
BURST_COUNT |
2 |
Max attempts per burst window. |
BURST_TIME |
30 |
Burst window in seconds. |
UPPER_COUNT |
10 |
Max attempts per upper window. |
UPPER_TIME |
3600 |
Upper window in seconds (1 hour). |
Public Rate-Limited Access
Preauth can provide rate-limited unauthenticated access to select public paths. This is useful for exposing public content (e.g., public repositories in Gitea) without requiring TOTP authentication, while protecting server resources from bot traffic.
When PUBLIC_PATHS is configured, requests to matching paths from
unauthenticated users are allowed through with a separate rate limiter.
Authenticated users bypass the public rate limiter entirely.
| Variable | Default | Description |
|---|---|---|
PUBLIC_PATHS |
'' (disabled) |
Comma-separated path patterns. See below. |
PUBLIC_BURST_COUNT |
100 |
Max requests per burst window per IP. |
PUBLIC_BURST_TIME |
60 |
Burst window in seconds. |
PUBLIC_UPPER_COUNT |
500 |
Max requests per sustained window per IP. |
PUBLIC_UPPER_TIME |
3600 |
Sustained window in seconds (1 hour). |
Path pattern syntax:
- Patterns are matched against the request path only (query string ignored).
- Patterns must start with
/. *matches one or more characters within a single path segment (not crossing/).**matches zero or more characters including/(crosses path segments).- An optional host prefix can restrict a pattern to a specific host
(e.g.,
code.example.com/public/**).
| Pattern | Matches | Does NOT match |
|---|---|---|
/public |
/public |
/public/, /public/repo |
/public/* |
/public/repo |
/public, /public/a/b |
/public/** |
/public/repo, /public/a/b/c |
/public |
host.com/api/** |
host.com/api/v1/status |
other.com/api/v1/status |
Example: Allow public access to Gitea's /public/ paths:
PUBLIC_PATHS=/public/**
PUBLIC_BURST_COUNT=100
PUBLIC_BURST_TIME=60
PUBLIC_UPPER_COUNT=500
PUBLIC_UPPER_TIME=3600
When a visitor exceeds the rate limit, they receive a 429 Too Many Requests
response with a Retry-After header. When within limits, they receive a
200 OK response (with no Remote-User header). Authenticated users receive
200 OK with their Remote-User header as normal.
Styling
All UI text and colors are configurable:
| Variable | Default | Description |
|---|---|---|
TITLE |
Pre-Authentication System |
Page title. |
BG_COLOR |
#029386 |
Background color. |
FG_COLOR |
#ffffff |
Foreground (text) color. |
ERROR_COLOR |
#ffb16d |
Error message color. |
ID_NAME |
Session ID |
Label for the ID field. |
TOKEN_NAME |
Authentication Token |
Label for the TOTP field. |
SUBMIT_NAME |
Submit |
Submit button text. |
ERROR_MESSAGE |
Unsuccessful login attempt |
Failed login message. |
TEAPOT_TITLE |
I'm a teapot |
Title when rate-limited (teapot mode). |
TEAPOT_MESSAGE |
I refuse to brew coffee |
Message when rate-limited (teapot mode). |
TOO_MANY_TITLE |
Too many requests |
Title when rate-limited (non-teapot). |
TOO_MANY_MESSAGE |
Try again later |
Message when rate-limited (non-teapot). |
Architecture
Client → Caddy → forward_auth → Preauth listeners → 200/401/418
Preauth is entirely event-listener-driven (no controllers). Each request passes through a priority-ordered chain of listeners:
- AcceptListener (priority 99) — Checks for valid session cookie.
- AllowListener (priority 88) — Checks for valid IP-based session.
- PublicAccessListener (priority 84) — If public paths are configured, allows rate-limited unauthenticated access to matching paths.
- RejectListener (priority 77) — Rate-limiting gate.
- LoginListener (priority 66) — Processes login attempts.
- InterceptListener (priority 55) — Renders login page or redirects.
- SecurityHeadersListener (response) — Adds security headers.
Security Model
- Cookies:
__Host-prefixed,SameSite=Strict,Secure,HttpOnly - Nonces: 15-byte random, single-use, 120-second TTL
- TOTP: ±1 period leeway (±30 seconds) for clock drift
- Backup codes: Case-insensitive, single-use, alphanumeric
- Rate limiting: Per-IP, compound sliding window, cannot be disabled
- Security headers: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS
- No cacheable login flow: The login page, failed logins, redirects,
and rate-limit pages are sent with strict anti-caching headers
(
no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0plusPragma,Expires,Surrogate-Control, andVary: *), and the login form'sfetch()opts out of the HTTP cache. Successful (2xx) responses are deliberately excluded — they are consumed by the proxy'sforward_authcheck and never reach the browser, so a protected service's own caching is not affected.
Cache
Preauth uses a dual-layer cache:
- APCu (in-memory) — Fast session and nonce lookups
- Filesystem — Persistent storage for container restarts
MonitorCacheKeys wraps the PSR-6 cache pool to track changes, so only
modified items are persisted to disk on shutdown.
Development
Code Style
This project follows PSR-12 and
includes php-cs-fixer as a dev dependency.
# Check for style violations
vendor/bin/php-cs-fixer fix --dry-run --diff
# Auto-fix
vendor/bin/php-cs-fixer fix
Running Tests
vendor/bin/phpunit
The test suite includes 293 tests with 100% code coverage (lines, methods, and classes). Both unit tests and functional tests (full HTTP kernel flow) are included.
Requirements
- PHP 8.4+
- Composer
- Xdebug (for coverage reports)
License
MIT — see license.txt.
Project Status
Running in production since June 2024, protecting multiple self-hosted services. The core authentication gate is complete and battle-tested.
See ROADMAP.md for planned features and CHANGELOG.md for version history.