Preamble
This post is intended to provide a practical guide to achieving a production-ready forward-authentication solution that can provide a polished unified login experience with MFA to arbitrary Caddy servers, in turn protecting multiple separately-hosted web apps and services.
Forward authentication
Ever since the release of Caddy version 2.5.1 (see: Release v2.5.1 · caddyserver/caddy · GitHub), there’s a new wrapper directive for the Caddyfile: forward_auth
, which configures a subrequest to an authenticating backend. Authelia is touted as the exemplar for this purpose (see: GitHub - authelia/authelia: The Single Sign-On Multi-Factor portal for web apps).
This separates the authentication & authorization handling to that other service, rather than handling that within the Caddy server itself.
Why not security in Caddy itself?
Form-based login, JWT authentication and authorization, with multi-factor support - this all obviously a smashing idea, but why not do this directly in Caddy? We have the excellent option in https://github.com/greenpau/caddy-security, which advertises all of these features.
Tight integration to the server itself may reduce footguns (of which there were indeed some encountered in the course of formulating this guide!), and may reduce complexity (of which there is indeed an added amount by virtue of configuring, running, and maintaining multiple separate services).
However, there are also some logical benefits to externalizing the authentication and authorization from the web server itself; but the biggest benefit in my view, by a long shot, is the incredible advantage of configuring security ONCE in a centralised manner for MULTIPLE Caddy servers.
As someone who hosts multiple services on a few different cloud platforms as well as maintaining my own home lab with a significant number of services by itself, logging in to one set of services and then navigating to another set of services and having to log in there instead is less than ideal; more so as it is not trivial to configure TOTP across many services, and juggling TOTPs becomes painful after a while. Being able to login on one set of services with a TOTP and then browse to a different set of services seamlessly - that’s a big win for me!
The stack
We’re going to be relying on the following moving parts that will comprise the full auth service:
- Caddy, as a HTTPS reverse proxy; in front of
- Authelia, obviously; using
- Redis, as the recommended memory store for login sessions; and
- Postgres, as a database for TOTP handles, auth logs, etc; all handled by
- Docker (Compose), to containerise all of the above
Then, on other projects we need to secure, we’ll be using Caddy again with forward auth.
0) Project directory
Pick somewhere to store the Compose file and various configuration/data files. This guide assumes you have a directory dedicated to this project and its stack (such as in my case /opt/docker/authelia
), in which you will be bind-mounting subdirectories to store config for each service.
1) Secrets
As part of the process, we will be generating a number of secrets; however, one secret file you will naturally need to supply yourself: your SMTP password for Authelia to send emails.
Every other secret we need we’ll be generating fresh, and they will all be stored and loaded from a series of files; this is the recommendation of the Authelia developers, as it keeps these secrets out of the container’s environment. Here are the secrets we want:
(Some more information about secrets management in Authelia can be found here.)
All of the above are recommended to be 64+ character long random alphanumeric strings, and Authelia’s documentation advises how these should be generated. For brevity’s sake, here are the commands you will want to run:
mkdir -p config/secrets
tr -cd '[:alnum:]' < /dev/urandom | fold -w 64 | head -n 1 | tr -d '\n' > config/secrets/JWT_SECRET
tr -cd '[:alnum:]' < /dev/urandom | fold -w 64 | head -n 1 | tr -d '\n' > config/secrets/SESSION_SECRET
tr -cd '[:alnum:]' < /dev/urandom | fold -w 64 | head -n 1 | tr -d '\n' > config/secrets/STORAGE_PASSWORD
tr -cd '[:alnum:]' < /dev/urandom | fold -w 64 | head -n 1 | tr -d '\n' > config/secrets/STORAGE_ENCRYPTION_KEY
tr -cd '[:alnum:]' < /dev/urandom | fold -w 64 | head -n 1 | tr -d '\n' > config/secrets/REDIS_PASSWORD
Lastly, you will want to nano config/secrets/SMTP_PASSWORD
and paste the SMTP password to the account you intend to use to send emails.
2) Authelia configuration
So, you just pull https://github.com/authelia/authelia/blob/master/config.template.yml and start working your way through one thousand, four hundred and thirty-eight (1438!) lines of co-
Nah, just kidding. I strongly recommend using that link as a reference when working on your config along with Prologue - Configuration - Authelia, but unless you want to spend a long time sorting things out, I’d recommend using a minimal base configuration:
/opt/docker/authelia/config/configuration.yml
# Miscellaneous https://www.authelia.com/configuration/miscellaneous/introduction/
# Set also AUTHELIA_JWT_SECRET_FILE
theme: auto
default_redirection_url: https://auth.example.com/ # Change me!
# First Factor https://www.authelia.com/configuration/first-factor/file/
authentication_backend:
file:
path: /config/users_database.yml
# Second Factor https://www.authelia.com/configuration/second-factor/introduction/
totp:
issuer: example.com # Change me!
# Security https://www.authelia.com/configuration/security/access-control/
access_control:
default_policy: two_factor
# Session https://www.authelia.com/configuration/session/introduction/
# Set also AUTHELIA_SESSION_SECRET_FILE
session:
domain: example.com # Change me!
# https://www.authelia.com/configuration/session/redis/
# Set also AUTHELIA_SESSION_REDIS_PASSWORD_FILE if appropriate
redis:
host: redis
port: 6379
# Storage https://www.authelia.com/configuration/storage/postgres/
# Set also AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE
# Set also AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
storage:
postgres:
host: database
database: authelia
username: authelia
# SMTP Notifier https://www.authelia.com/configuration/notifications/smtp/
# Set also AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE
notifier:
smtp:
host: smtp.example.com # Change me!
port: 465 # Change me!
username: you@example.com # Change me!
sender: "Authelia <authelia@example.com>" # Change me!
Save the above to config/configuration.yml
(note: NOT config/config.yml
) - then go through and change all the Change me!
items, which should mostly be self-explanatory. Each section has its link to the documentation commented; refer to the docs for more detail and also to see what configuration lines have been omitted in favor of their defaults.
3) Authelia Users Database
Now we need to set up who can actually log in with a simple yaml file as our user database. The format of this file is described in Passwords - Reference - Authelia, but here is another minimal base configuration to start from:
/opt/docker/authelia/config/users_database.yml
# User file database https://www.authelia.com/reference/guides/passwords/#yaml-format
# Generate passwords https://www.authelia.com/reference/guides/passwords/#passwords
# docker run --rm -it authelia/authelia:latest authelia crypto hash generate argon2
users:
myusername:
password: [hashed password]
displayname: "My User"
email: changeme@example.com
Change myusername
to your username of choice and set the display name and email address.
To generate your password, we’re going to use Authelia itself to handle the crypto hash generation. This is done rather handily directly through Docker; simply run:
docker run --rm -it authelia/authelia:latest authelia crypto hash generate argon2
And type in your password of choice into the secure prompt. After confirming, it will spit out a Digest: [hashed password]
string; copy the [hashed password]
(it should start with $argon2id$v=19$m=65536,t=3,p=4$
or similar) into the password
stanza of the users database file.
Save this file to config/users_database.yml
.
4) Review files and secure them
You should now have the following files in place:
$ sudo tree /opt/docker/authelia/config
/opt/docker/authelia/config
├── configuration.yml
├── secrets
│ ├── JWT_SECRET
│ ├── REDIS_PASSWORD
│ ├── SESSION_SECRET
│ ├── SMTP_PASSWORD
│ ├── STORAGE_ENCRYPTION_KEY
│ └── STORAGE_PASSWORD
└── users_database.yml
1 directory, 8 files
Go over your configuration.yml
and users_database.yml
again now to double check everything looks correct. Then, make sure this entire config
directory is secured:
sudo chown -R root:root /opt/docker/authelia/config
sudo chmod -R 600 /opt/docker/authelia/config
5) Caddy configuration
First, add a folder that’s going to contain your Caddy configuration and TLS assets:
mkdir /opt/docker/authelia/caddy
Then lets add a Caddyfile:
nano /opt/docker/authelia/caddy/Caddyfile
And we’re going to paste in the following configuration:
auth.example.com {
reverse_proxy app:9091
}
Make sure to update auth.example.com
to the domain name you’ll be serving Authelia on. This domain name must point at your server and be publicly reachable.
6) Docker Compose file
Add your Compose file now:
nano /opt/docker/authelia/docker-compose.yml
We’re going to start with the following configuration:
/opt/docker/authelia/docker-compose.yml
name: "authelia"
services:
proxy:
image: caddy:latest
#restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/data:/data
- ./caddy/Caddyfile:/etc/caddy/Caddyfile
app:
image: authelia/authelia:latest
restart: unless-stopped
depends_on:
- database
- redis
volumes:
- ./config:/config
environment:
AUTHELIA_JWT_SECRET_FILE: /config/secrets/JWT_SECRET
AUTHELIA_SESSION_SECRET_FILE: /config/secrets/SESSION_SECRET
AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE: /config/secrets/SMTP_PASSWORD
AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE: /config/secrets/STORAGE_ENCRYPTION_KEY
AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE: /config/secrets/STORAGE_PASSWORD
AUTHELIA_SESSION_REDIS_PASSWORD_FILE: /config/secrets/REDIS_PASSWORD
database:
image: postgres:15
restart: unless-stopped
volumes:
- ./postgres:/var/lib/postgresql/data
environment:
POSTGRES_USER: "authelia"
POSTGRES_PASSWORD: "[snip]"
redis:
image: redis:7
restart: unless-stopped
command: "redis-server --save 60 1 --loglevel warning --requirepass [snip]"
volumes:
- ./redis:/data
You’re going to need to make a few quick edits:
- Change the
POSTGRES_PASSWORD
under thedatabase:
service to the contents of thesecrets/STORAGE_PASSWORD
file. - Change
--requirepass
in the Rediscommand:
to the contents of thesecrets/REDIS_PASSWORD
file.
7) First run
Start by spinning up just Authelia, Postgres, and Redis. Don’t daemonize them yet:
docker compose up app database redis
Stick around and watch the logs. Ensure there are no glaring issues that pop up that might warrant your attention. Using CTRL+C will kill the containers when you’re done; either start troubleshooting any log errors now, or move on to…
Spin up just the Caddy container. Don’t daemonize it yet:
docker compose up proxy
Stick around and watch the logs. If something goes wrong with certificate requisition, Caddy should exit, and you’ll need to troubleshoot that now; double check DNS, your domain name, and ports 80 and 443 on your server are publicly accessible. If not, CTRL+C to kill the container.
If everything checked out A-OK:
- Edit
docker-compose.yml
and uncommentrestart: unless-stopped
on the Caddy service. - Run
docker compose up -d
to start the entire stack. - Browse to your Authelia and try to log in and configure your TOTP!
8) Securing other applications
a) From across the internet
Add the following snippet to your Caddyfile:
(secure) {
forward_auth {args.0} https://auth.example.com {
uri /api/verify?rd=https://auth.example.com
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
header_up Host {upstream_hostport}
}
}
Then, import it in each site block that needs to be secured:
# Secure the entire subdomain
foo.example.net {
import secure *
reverse_proxy upstream:8080
}
# Secure a subpath
bar.example.net {
import secure /api
reverse_proxy backend:8080
}
# Secure based on a matcher
foobar.example.net {
@myMatcher query key=val
import secure @myMatcher
reverse_proxy application:8080
}
b) On the same host
If you’re running other services in your Compose stack and need to secure them, you don’t need to forward_auth
to the external domain - in fact, that’d be a bit inefficient! Instead, you can forward_auth
directly to it:
(secure) {
forward_auth {args.0} app:9091 {
uri /api/verify?rd=https://auth.example.com
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
}
Note that while the forward_auth
stays the same, the uri
continues to point to the external address, because while Caddy might be on the internal Compose network, the external client isn’t and needs to be redirected to the domain name.