Forward-Auth for Your Homelab With Tinyauth
Every self-hosted service you put on the internet is an access-control decision. The default in most homelabs is to skip the decision: the subdomain works, the service is useful, and there is always something more pressing than authentication. But every service without a real auth layer is a service rotting on a timer. Scrapers find it, bots brute-force it, and if it fronts anything with upstream dependencies — SearXNG, Grafana, any tool that makes outbound API calls — those upstreams start fighting back.
A couple of weeks ago I published a self-hosted SearXNG instance at search.c0xl.ch. The predictable happened: scrapers identified the SearXNG signature, began hammering it, and the upstream engines started CAPTCHAing the egress IP. The instance worked, then rotted, then worked again after a manual restart. Not a sustainable equilibrium.
The real answer is authentication. The question is which kind.
The four ways to protect a homelab service
- Do nothing. Works for about three weeks. Then the scrapers arrive.
- Basic auth. Survives anywhere, unattended for years. Ugly on mobile, no TOTP, every service gets its own password pair.
- VPN-only. Works perfectly until you need the service from a device that isn't on the VPN — a phone on hotel Wi-Fi, a work laptop behind a corporate proxy, a friend's computer for ten minutes.
- Forward-auth with an identity provider. One login, SSO across services you choose, TOTP built in, OAuth delegation if you want it. Correct answer at the cost of running one more container.
Option 4 is the right default. The remaining question is which IdP.
Why tinyauth
Keycloak is how you authenticate an enterprise. A 512 MB Java process with an embedded application server to protect three homelab services is architecturally embarrassing and operationally punishing.
Authelia and Authentik are both good. Both are heavier than they need to be at homelab scale. Authentik wants Postgres, Redis, a worker, and a server — four containers for a use case that genuinely needs one. Authelia is lighter but still expects you to write YAML access-control policies for a scenario that is "let me and nobody else in."
Tinyauth is a single Go binary. About 30 MB at idle, SQLite for state, TOTP built in, OAuth delegation available (GitHub, Google, generic OIDC), username-and-password as the default. It does forward-auth and nothing else. For everything up to maybe ten services with modest policy requirements, this is the correct tradeoff.
One caveat: tinyauth is not enterprise auth. No SAML, no SCIM, no audit log streaming. If you are putting it in front of customer-facing services, stop here and go learn Keycloak. Tinyauth is a homelab tool that happens to be production-grade for that scope.
Cookie scoping: the decision that will bite you if you get it wrong
Tinyauth sets its session cookie on the parent domain of whatever URL you tell it to live at. Deploy it at auth.example.com → cookie on .example.com → every service at *.example.com that includes the forward-auth snippet in its Caddy config shares the session. That's SSO.
It is also almost always the wrong default. Two reasons:
- Blast radius. One cookie covering everything means one compromised cookie compromising everything. Scope expansion should be an architectural decision, not a side effect of paste-and-go configuration.
- Defense against your own future mistakes. Six months from now, late at night, tired, you will add
import tinyauthto a service you didn't think through. Narrow cookie scope forces that service into a redirect loop until you reconsider. Broad cookie scope silently extends full trust, and you never notice.
The right default is to deploy tinyauth at auth.<service>.example.com, not auth.example.com. The cookie then scopes to .<service>.example.com, covers <service>.example.com and any future sub-subdomains, and cannot reach anything else on your domain. If SSO across your whole apex later becomes a real requirement, you rename one environment variable and one DNS record, and the scope expands on purpose.
For my SearXNG instance that meant auth.search.c0xl.ch serving the tinyauth UI, with the cookie scoped to .search.c0xl.ch.
Deployment
Directory ~/homelab/tinyauth/. Three files.
.env (chmod 600, gitignored):
TINYAUTH_AUTH_USERS=chris:$2a$10$...bcrypthash...
TINYAUTH_AUTH_SESSIONEXPIRY=604800
TINYAUTH_AUTH_SECURECOOKIE=true
TINYAUTH_ANALYTICS_ENABLED=false
Generate the user string with:
docker run -it --rm ghcr.io/steveiliop56/tinyauth:v5 user create --interactive
docker-compose.yml:
1services:
2 tinyauth:
3 image: ghcr.io/steveiliop56/tinyauth:v5
4 container_name: tinyauth
5 restart: unless-stopped
6 networks: [proxy-net]
7 env_file: [.env]
8 environment:
9 - TINYAUTH_APPURL=https://auth.search.c0xl.ch
10 - TINYAUTH_DATABASE_PATH=/data/tinyauth.db
11 volumes:
12 - ./data:/data
13 cap_drop: [ALL]
14
15networks:
16 proxy-net:
17 external: true
The /data volume is non-optional. Tinyauth's session-signing key is generated into the SQLite database at first start. Without persistence, every restart invalidates every session, because the key regenerates.
Caddyfile — snippet at the top, auth host unprotected, one line to protect each downstream service:
(tinyauth) {
forward_auth tinyauth:3000 {
uri /api/auth/caddy
copy_headers Remote-User Remote-Name Remote-Email Remote-Groups
}
}
auth.search.c0xl.ch {
reverse_proxy tinyauth:3000
}
search.c0xl.ch {
import tinyauth
reverse_proxy searxng:8080
}
Order matters inside the protected site block: import tinyauth must come before reverse_proxy. Caddy executes directives in the order written; authentication has to decide before the proxy dispatches.
Every additional service you want protected under the same identity is now a one-line change.
A gotcha worth naming
Tinyauth v5 renamed a lot of its environment variables. A lot of tutorials, forum answers, and generated snippets still show v3-era names — TINYAUTH_APP_URL, TINYAUTH_USERS, TINYAUTH_SECRET. These are all wrong for v5. Paste them and the container does this at startup:
fatal ... app URL cannot be empty, perhaps config loading failed
...then restarts in a loop forever, because the config loader tolerates unknown keys but refuses to run without the known ones.
The correct v5 names:
TINYAUTH_APPURL— no underscore betweenAPPandURLTINYAUTH_AUTH_USERS— section-prefixed- No
TINYAUTH_SECRETat all — session signing moved into the database in v4→v5, so there is no external secret to set
This is the kind of breakage that cost me an evening the first time and ten seconds the second. The reliable rule: when a service goes into a restart loop with a config loading failed message, go to the project's current configuration reference and verify every variable name against the file you wrote. Not a tutorial. Not a blog post. The reference documentation for the version you are actually running.
What this actually defends against
Forward-auth does not make your homelab anonymous. It does not hide your DNS records. Every bot on the internet can still see that search.c0xl.ch exists and that auth.search.c0xl.ch serves a login page.
What it does is the thing you actually needed:
- Makes the service useless to scrapers. They don't solve interactive login flows. A 302 to the auth host is the end of the interaction as far as automated tooling is concerned.
- Centralizes identity. One bcrypt hash to rotate, one TOTP secret to enroll, one place to check who is logging in.
- Makes adding new authenticated services a one-line change. The incremental cost of the second, third, fourth protected service is essentially zero.
Authentication was always the answer for self-hosted services exposed to the internet. Tinyauth is the smallest viable form of it that doesn't compromise on security primitives. Deploy it scoped narrowly, deploy it early, and write down why you chose the cookie scope you chose — for the version of you who reads the configuration six months from now and has to remember why.