Two lines of YAML put a service behind login. Basic auth is on by default everywhere; this rung is about controlling it: per-service credentials, path bypasses, and how to wire in your own SSO when basic auth isn't enough.
Level: 5 · Reading time: 15 min
tpl.env.hello, api — whichever).Already know this rung? Skip to Level 6 — custom routing where you customise headers, strip path prefixes, and drop into raw Caddy JSON.
You've already been using authentication without realising it. Every
asd expose and asd net apply route picks up basic auth by
default, because features.disable_authentication is false out
of the box. The credentials are a single project-wide username +
generated password.
This rung covers the three real questions:
asd expose auth)basicAuth: in the manifest)authBypassPaths)Forward-auth / SSO (Authentik, Keycloak, your own OIDC provider) is
a step beyond — it lives in Level 6 — custom routing
and the cookbook/put-an-api-behind-sso recipe.
$ asd expose auth
🔧 Basic Auth Credentials
Username: asd
Password: <generated>
📝 These credentials protect services exposed via 'asd expose'.
Auth mode: always
Every route generated by asd net apply (and every asd expose)
sits behind these credentials. The browser prompts for them when you
open the public URL.
In the service manifest:
# packages/api/net.manifest.yaml
id: api
endpoint:
url: "http://127.0.0.1:4000"
caddy:
hostRoute:
host: "api-${{ macro.tunnelClientId() }}.${{ macro.tunnelEndpoint() }}"
tunnel:
public: true
basicAuth:
enabled: true
username: "${{ env.API_USER }}"
password: "${{ env.API_PASSWORD }}"
realm: "API"
With API_USER / API_PASSWORD in .env (or per-mode in tpl.env),
the api service now has its own credentials separate from the
project-wide ones. Browsers see a different auth realm prompt.
$ asd net apply
✅ Apply complete: 0 seeded, 2 routes
For webhooks, health checks, OAuth callbacks — anything that needs to
be reachable without a credential:
# packages/api/net.manifest.yaml
caddy:
hostRoute: { … }
authBypassPaths:
- /api/health
- /api/webhooks/*
Those paths skip basic auth and return whatever the upstream serves.
Everything else still prompts.
Globally — only do this when you know the service should be open:
# asd.yaml
features:
disable_authentication: true
Per service — pass --auth=none to asd expose:
$ asd expose 3000 --auth=none
✓ Exposed http://localhost:3000 → https://3000-xyz1.eu2.tn.example.com (no auth)
$ curl -s -o /dev/null -w "%{http_code}\n" https://api-xyz1.eu2.tn.example.com/
401
$ curl -s -o /dev/null -w "%{http_code}\n" -u "$API_USER:$API_PASSWORD" \
https://api-xyz1.eu2.tn.example.com/
200
$ curl -s -o /dev/null -w "%{http_code}\n" https://api-xyz1.eu2.tn.example.com/api/health
200
Unauthenticated requests get 401; the right credentials get 200;
the bypass path goes through regardless. The browser sees a standard
HTTP basic auth dialog (realm "API" per the manifest above).
asd expose auth keeps showing the project-wide credentials; the
per-service ones live in your .env. To rotate either: change the
password (in .env or via asd caddy sync-auth for the global set),
re-run asd net apply.
Three things to internalise:
Basic auth is opt-out, not opt-in. features.disable_authentication: false
is the default, and the basicauth handler is attached to every
route Caddy serves unless explicitly excluded. That's why Level 1
already had authentication — you just used the default credentials.
The friction is intentional: if you forget to disable auth on a
service that should be open, the failure is a 401 (visible) not
silent public exposure.
basicAuth: is a per-service overlay, not a replacement.
When you declare basicAuth: { username, password }, Caddy attaches
those credentials to this route. Other routes still use the
project-wide ones. There's no "auth chain" to reason about —
each route stands alone.
authBypassPaths is path-level, not method-level. It matches
on the request path before basic auth runs, so a POST /api/webhooks/stripe
reaches your upstream without a credential. Use it for endpoints
that cannot prompt for credentials (machine-to-machine
callbacks, health probes for an external monitor) — not for "stuff
I don't want to type a password for".
For the JSON-level view of what asd generates, run asd net discover | jq '.manifests[] | select(.id=="api") | .caddy' after applying.
learn/07-build-a-module for the deep pattern, or apply it directly via caddy.routes per the schema reference.cookbook/put-an-api-behind-sso.reference/net-manifest — basicAuth, authPolicy, authBypassPaths fields.