Authwall documentation
Authwall is an authentication proxy — it sits between clients and an internal
app, handles sign-in, and forwards authenticated requests with an
X-Auth-User header.
Contents
- Overview — what Authwall is, runnable
docker runrecipes, the project's philosophy, secret management, and related projects. - Architecture — a high-level map of Authwall's big blocks.
- Getting started — a one-command quick start, then the full Docker Compose setup in front of a real app.
- Recipes — runnable setups from a one-line start to personal access tokens and WebSockets.
- Deployment — HTTPS, the session secret, production databases, logging, and health checks.
- Deployment examples — runnable Docker Compose setups for the direct, reverse-proxy, and sidecar topologies (nginx and Caddy).
- Sign-in flows — password, magic link, magic code, and
OAuth, and how
AUTHWALL_FLOWSselects them. - OAuth providers — per-provider setup walkthroughs.
- Emails — the transactional email templates and how to customize them.
- CLI tools — the
bin/utilities for running, building, and operating Authwall. - Security model — the
X-Auth-Usertrust boundary, sessions, CSRF, rate limiting, access control, and error-report redaction. - Configuration reference — every environment variable, with defaults, validation rules, and examples.
- Glossary — terms used throughout the docs and code.
Overview
Authwall is an authentication proxy — it sits between clients and an internal app,
handling sign-in (email/password, magic links, and OAuth) and forwarding
authenticated requests with an X-Auth-User header.
client → authwall → your appsequenceDiagram
participant client
participant authwall
participant your_app
client ->> authwall: request
authwall ->> your_app: request (X-Auth-User)
your_app -->> authwall: response
authwall -->> client: responseQuick start
Authwall runs with zero configuration: by default it uses SQLite and open
registration. Each recipe below is a complete docker run command — pick the
one that matches how you want users to sign in. More setups — seeded users,
personal access tokens, WebSockets — are in Recipes.
Open registration (username + password only)
docker run --rm -p 3000:3000 \
-e AUTHWALL_UPSTREAM_URL=http://internal:8080 \
vbarbarosh/authwallBehavior:
- sign-in: username + password
- registration: open
- email features: disabled
- storage: SQLite (ephemeral unless volume mounted)
Open registration (username/email + password + magic link)
docker run --rm -p 3000:3000 \
-e AUTHWALL_UPSTREAM_URL=http://internal:8080 \
-e AUTHWALL_RESEND_KEY=re_xxx \
-e AUTHWALL_RESEND_FROM="Authwall <noreply@myapp.test>" \
vbarbarosh/authwallBehavior:
- sign-in: username/email + password
- magic link: enabled
- email confirmation: enabled
- registration: open
Google OAuth only
Create a Google OAuth client and add this authorized redirect URI:
https://myapp.test/auth/google/callbackThen run:
docker run --rm -p 3000:3000 \
-e AUTHWALL_PUBLIC_URL=https://myapp.test \
-e AUTHWALL_UPSTREAM_URL=http://internal:8080 \
-e AUTHWALL_GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com \
-e AUTHWALL_GOOGLE_CLIENT_SECRET=GOCSPX_xxx \
-e AUTHWALL_GOOGLE_REDIRECT_URL=https://myapp.test/auth/google/callback \
vbarbarosh/authwallBehavior:
- sign-in: Google OAuth
- registration: open for Google accounts
- email identity: added only when Google reports a verified email
- email features: disabled unless a mailer is configured
Google OAuth with an email allowlist
Same Google OAuth client as above, plus AUTHWALL_ALLOWED_EMAILS to limit
sign-in to named addresses:
docker run --rm -p 3000:3000 \
-e AUTHWALL_PUBLIC_URL=https://myapp.test \
-e AUTHWALL_UPSTREAM_URL=http://internal:8080 \
-e AUTHWALL_GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com \
-e AUTHWALL_GOOGLE_CLIENT_SECRET=GOCSPX_xxx \
-e AUTHWALL_GOOGLE_REDIRECT_URL=https://myapp.test/auth/google/callback \
-e AUTHWALL_ALLOWED_EMAILS=alice@example.com,bob@example.com \
vbarbarosh/authwallBehavior:
- sign-in: Google OAuth
- registration: open for listed Google accounts
- allowed users: only verified Google emails listed in
AUTHWALL_ALLOWED_EMAILS - everyone else: rejected
Notes
- If no mailer is configured, email-based flows are disabled automatically
- First user is created via sign-up (no bootstrap user required)
- Data is stored inside the container unless a volume is mounted
Philosophy
- Zero-config start —
docker runworks with no required variables. - Env-driven configuration — everything is set through
AUTHWALL_*environment variables; see the configuration reference. - Fail loudly — anything you request explicitly (a mailer, a flow, a provider) must be fully configured, or Authwall refuses to start instead of silently falling back.
- Sensible defaults for local development — SQLite, open registration, no
mailer required; rarely-needed knobs live in
config/settings.yaml.
Secret management
AUTHWALL_SECRET is optional.
Startup order is:
- Use
AUTHWALL_SECRETwhen it is set. - Otherwise, load
/app/data/secret.keyif it already exists. - Otherwise, generate a new random secret, write it to
/app/data/secret.key, and use that value.
Why this default exists:
- Authwall derives its session secret from one root secret, so that root value must stay stable across restarts.
- Requiring an env var for every local or single-host deployment makes first boot harder and encourages weak placeholder values.
- Persisting the generated secret in the data directory keeps restarts deterministic as long as the data volume is preserved.
- An explicit
AUTHWALL_SECRETstill takes precedence, which is the better fit when secrets are managed by the runtime or an external secret store.
If you rotate either AUTHWALL_SECRET or data/secret.key, existing sessions and CSRF tokens become invalid by design.
Related projects
- Auth0 – hosted identity platform
- WorkOS – enterprise SSO and user management
- Supabase Auth – open-source auth with many integrations
- Netlify GoTrue – JWT-based API for managing users and issuing tokens
- Firebase Auth – simple, multi-platform sign-in
- Amazon Cognito – AWS authentication and access control
- Authentik – open-source identity provider
- Keycloak – open-source identity and access management
- Authelia – open-source authentication portal with MFA and SSO
- Zitadel – open-source identity infrastructure
- Ory – composable open-source IAM
- Tinyauth – tiny OIDC server for self-hosted applications
- Logto – modern auth infrastructure for developers
- Clerk – authentication and complete user management
- OAuth2 Proxy – reverse proxy that authenticates via OAuth providers
- Kanidm – simple, secure identity management platform
- lldap – light LDAP implementation
- Rauthy – OIDC single sign-on and IAM
- Casdoor – authentication and authorization platform
- PocketBase – open-source backend in one file
Architecture
Authwall is an authentication proxy: it sits in front of an app, handles sign-in, and forwards authenticated requests upstream. This page is a high-level map of its big blocks — follow the links for detail.
The big blocks, with their options:
flowchart LR
subgraph authwall [Authwall]
server[HTTP server / proxy]
flows[Sign-in flows]
sessions[Sessions]
access[Access control]
end
authwall -->|X-Auth-User| app([Upstream app])
authwall --> db[(Database)]
authwall --> mailer[Mailer]
authwall --> oauth[OAuth providers]
authwall --> logger[Logger]
authwall --> sentry[Sentry]
click server "#deployment"
click flows "#sign-in-flows"
click sessions "#security"
click access "#config-access-rules"
click db "#config-authwall_db"
click mailer "#config-authwall_mailer"
click oauth "#oauth-providers"
click logger "#config-authwall_logger"
click sentry "#config-sentry"
Getting started
Authwall is an authentication proxy. It sits in front of your app, handles
sign-in, and forwards authenticated requests with an optional X-Auth-User
header:
client → authwall → your appflowchart LR
client --> authwall --> app["your app"]There are two paths below: a one-command Quick start to see Authwall running immediately, and a full Docker Compose setup that puts it in front of a real app with a persistent database.
Quick start
The fastest way to see Authwall. This needs only Docker and a free port 3000:
docker run --rm -p 3000:3000 \
-e AUTHWALL_UPSTREAM_URL=http://localhost:8080 \
vbarbarosh/authwallOpen http://localhost:3000, choose Sign up, and create the first account.
What this gives you:
- Storage: SQLite — Authwall's default whenever
AUTHWALL_DBis not set. No database to install or configure. Here it lives inside the container and is discarded when the container stops (--rm); mount a volume at/app/datato keep it. - Sign-in: username + password, with open registration.
- Email features: disabled, since no mailer is configured.
Point AUTHWALL_UPSTREAM_URL at your own app to see authenticated requests
proxied through it. For a lasting setup, continue with the Compose walkthrough
below.
Full setup with Docker Compose
The rest of this guide uses the docker-compose.yaml shipped in this
repository. By the end you will have three containers running — Authwall, a
MySQL database, and a demo upstream app — and you will have signed in and seen
a request reach the upstream with the X-Auth-User header attached.
Prerequisites:
- Docker with the Compose plugin (
docker compose versionshould work). - Port
3000free on the host.
1. The Compose file
The shipped docker-compose.yaml defines three services:
| Service | Image | Role |
|---|---|---|
authwall |
vbarbarosh/authwall |
The auth proxy, published on host port 3000 |
mysql |
mysql:8.4.8 |
Persistent storage for users and sessions |
echo-server |
jmalloc/echo-server |
A stand-in upstream app that echoes each request back |
The authwall service is configured entirely through environment variables:
environment:
AUTHWALL_PUBLIC_URL: http://localhost:3000
AUTHWALL_UPSTREAM_URL: http://echo-server:8080
AUTHWALL_DB: mysql://authwall:authwall@mysql/authwallAUTHWALL_PUBLIC_URL— the URL users reach Authwall on. Used to build links and redirects.AUTHWALL_UPSTREAM_URL— the upstream app. Here it points at theecho-serverservice by its Compose name. Swap this for your own app's URL.AUTHWALL_DB— the database connection. When it is unset, Authwall falls back to a local SQLite database (as in the Quick start above); the Compose file sets it to MySQL instead, with a commented-out PostgreSQL alternative if you prefer it.
See the configuration reference for every available variable.
2. Create the .env file
The authwall service declares env_file: .env, so Compose expects that file
to exist. For this first run it can be empty:
touch .envLater, secrets and OAuth credentials go here — for example:
# .env
AUTHWALL_RESEND_KEY=re_xxx
AUTHWALL_RESEND_FROM=Authwall <noreply@myapp.test>
AUTHWALL_GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
AUTHWALL_GOOGLE_CLIENT_SECRET=GOCSPX_xxx
AUTHWALL_GOOGLE_REDIRECT_URL=http://localhost:3000/auth/google/callback3. Start the stack
docker compose up -dOn first boot Authwall waits for MySQL to accept connections, then applies its database migrations automatically — no manual migration step is needed. Watch the progress with:
docker compose logs -f authwallYou are ready once the log shows the database is ready and the HTTP server is listening.
4. Sign in
Open http://localhost:3000 in a browser.
With no mailer and no OAuth credentials configured, Authwall starts in its simplest mode:
- sign-in is username + password
- registration is open — the first visitor signs up to create the first account
- email-based features are disabled
Choose Sign up, create a username and password, and you will land on the proxied upstream app.
5. Confirm the proxy works
Because the demo upstream is echo-server, the page you see after signing in
is the echo of your own request. Look for the X-Auth-User header in that
echoed output — Authwall adds it to every authenticated request so your real
app can identify the signed-in user without implementing sign-in itself.
Requests from a signed-out browser never reach the upstream; they are redirected to the Authwall sign-in page instead.
6. Where data lives
The Compose file mounts host volumes so nothing is lost on restart:
volumes:
- ./data/authwall:/app/data # Authwall's data dir (incl. the generated secret)
- ./data/mysql:/var/lib/mysql # MySQL data files./data/authwall holds secret.key, the root secret Authwall derives its
session secret from. Keep this directory across restarts, or existing sessions
will be invalidated. See Secret management.
Stopping and resetting
docker compose down # stop the stack, keep all data
docker compose down && rm -rf data # stop and wipe users, sessions, secretNext steps
- Configuration reference — every environment variable.
- Point
AUTHWALL_UPSTREAM_URLat your own app instead ofecho-server. - Add a mailer to unlock magic-link sign-in and email confirmation
(see the
AUTHWALL_MAILERsection of the configuration reference). - Add OAuth providers (Google, GitHub, Microsoft, Facebook, X, Discord) via
their
*_CLIENT_ID/*_CLIENT_SECRET/*_REDIRECT_URLvariables. - Restrict who may sign in with
AUTHWALL_ALLOWED_EMAILS/AUTHWALL_ALLOWED_DOMAINS.
Recipes
Runnable setups, ordered from the smallest possible start to a deliberate deployment with API tokens and WebSockets. Every command works as-is — swap the example values for your own. For what each variable does, see the configuration reference.
Just run it (one line)
docker run --rm -p 3000:3000 vbarbarosh/authwallOpen http://localhost:3000, choose Sign up, and create the first account.
- sign-in: username + password, open registration
- storage: SQLite inside the container — discarded on stop (
--rm) - upstream: none yet — after sign-in, proxying fails until
AUTHWALL_UPSTREAM_URLpoints at a real app (next recipe)
Protect an app and keep its data
The first real setup: point Authwall at your app and mount a volume so users, sessions, and the generated secret survive restarts.
docker run -d --name authwall -p 3000:3000 \
-e AUTHWALL_UPSTREAM_URL=http://internal:8080 \
-v ./data/authwall:/app/data \
vbarbarosh/authwall- every request not under
/authis proxied to the upstream; authenticated requests carryX-Auth-User ./data/authwallholds the SQLite database andsecret.key— keep it, or every restart signs everyone out (see Secret management)- the upstream must be reachable only through Authwall, or the header can be forged
Bootstrap an admin user
AUTHWALL_SEED creates users at startup, so an
instance comes up with a known account instead of relying on whoever signs up
first.
docker run -d --name authwall -p 3000:3000 \
-e AUTHWALL_UPSTREAM_URL=http://internal:8080 \
-e AUTHWALL_SEED='admin:change-me:admin@myapp.test' \
-v ./data/authwall:/app/data \
vbarbarosh/authwall- the
adminuser exists on first boot; sign in withadmin/change-meand change the password from the profile - already-existing users are left alone, so the variable is safe to keep set across restarts
Team sign-in with Google
Everyone at one domain signs in with their Google account; nobody else gets
in. Needs a Google OAuth client with
https://myapp.test/auth/google/callback as an authorized redirect URI.
docker run -d --name authwall -p 3000:3000 \
-e AUTHWALL_PUBLIC_URL=https://myapp.test \
-e AUTHWALL_UPSTREAM_URL=http://internal:8080 \
-e AUTHWALL_GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com \
-e AUTHWALL_GOOGLE_CLIENT_SECRET=GOCSPX_xxx \
-e AUTHWALL_GOOGLE_REDIRECT_URL=https://myapp.test/auth/google/callback \
-e AUTHWALL_ALLOWED_DOMAINS=myapp.test \
-v ./data/authwall:/app/data \
vbarbarosh/authwall- only Google is offered — in
automode, configuring an OAuth provider turns the password flows off - the access rules admit only verified
@myapp.testGoogle accounts; everyone else is rejected - ban a single address on top of the domain rule with
AUTHWALL_DENIED_EMAILS=fired@myapp.test
API clients with personal access tokens
Browser sessions don't help a script or a CI job.
AUTHWALL_PERSONAL_ACCESS_TOKENS
lets signed-in users mint bearer tokens from the profile page.
docker run -d --name authwall -p 3000:3000 \
-e AUTHWALL_UPSTREAM_URL=http://internal:8080 \
-e AUTHWALL_PERSONAL_ACCESS_TOKENS=true \
-v ./data/authwall:/app/data \
vbarbarosh/authwallAn API client sends the token on every request and reaches the upstream as its owner:
curl -H 'Authorization: Bearer awp_…' https://myapp.test/api/things
curl -H 'Authorization: Bearer awp_…' https://myapp.test/auth/status- the raw token is shown once at creation; Authwall stores only a hash
- a token authenticates upstream requests and
/auth/status, but cannot manage the account — no creating tokens, changing passwords, or deleting the account
WebSockets behind Authwall
The deliberate setup: a real-time app whose non-browser clients (desktop
apps, workers) hold WebSocket connections through Authwall.
AUTHWALL_WEBSOCKETS proxies upgrades, and
since upgrades authenticate with a bearer token, personal access tokens must
be enabled too.
docker run -d --name authwall -p 3000:3000 \
-e AUTHWALL_PUBLIC_URL=https://myapp.test \
-e AUTHWALL_UPSTREAM_URL=http://internal:8080 \
-e AUTHWALL_PERSONAL_ACCESS_TOKENS=true \
-e AUTHWALL_WEBSOCKETS=true \
-v ./data/authwall:/app/data \
vbarbarosh/authwallA client authenticates the upgrade with the Authorization header on the
handshake:
const ws = new WebSocket('wss://myapp.test/realtime', {
headers: {Authorization: 'Bearer awp_…'},
});- upgrades are accepted on any path not under
/auth/; Authwall validates the token, strips the credential, and forwards the upgrade with the same trustedX-Auth-Userheader it sets on HTTP requests - the browser
WebSocketAPI cannot set theAuthorizationheader, so this path is for non-browser clients; there is no cookie-based WebSocket authentication yet - failed upgrade attempts share the bearer-token rate limiter with HTTP requests
With Docker Compose
docker run is fine for one container, but once Authwall has a database and an
app of its own to manage, a docker-compose.yaml keeps the whole stack in one
declarative file. Save any block below as docker-compose.yaml and run:
docker compose upEach uses jmalloc/echo-server as a stand-in upstream so the X-Auth-User
header is visible in the echoed response — swap it for your own app. For the
reverse-proxy and sidecar topologies, see the runnable
deployment examples.
Protect an app
The Compose form of keeping your data:
Authwall, your app, and a volume so users, sessions, and the secret survive
docker compose down.
services:
authwall:
image: vbarbarosh/authwall
restart: unless-stopped
environment:
AUTHWALL_PUBLIC_URL: http://localhost:3000
AUTHWALL_UPSTREAM_URL: http://app:8080
ports:
- 3000:3000
volumes:
- ./data/authwall:/app/data
depends_on:
- app
app:
image: jmalloc/echo-server
restart: unless-stopped- Authwall reaches the upstream as
app:8080over the Compose network; the app is never published, so it is reachable only through Authwall ./data/authwallholds the SQLite database andsecret.key— keep it across restarts (see Secret management)
Back it with PostgreSQL
SQLite is fine for a single instance; point AUTHWALL_DB
at a real database when you want managed backups or more than one Authwall
replica. Authwall waits for the database, then applies its migrations on first
boot — no manual migration step.
services:
authwall:
image: vbarbarosh/authwall
restart: unless-stopped
environment:
AUTHWALL_PUBLIC_URL: http://localhost:3000
AUTHWALL_UPSTREAM_URL: http://app:8080
AUTHWALL_DB: postgres://authwall:authwall@postgres/authwall
ports:
- 3000:3000
volumes:
- ./data/authwall:/app/data
depends_on:
- postgres
- app
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: authwall
POSTGRES_USER: authwall
POSTGRES_PASSWORD: authwall
volumes:
- ./data/postgres:/var/lib/postgresql/data
app:
image: jmalloc/echo-server
restart: unless-stopped- users and sessions now live in Postgres;
./data/authwallstill holdssecret.key, so keep it too — losing the secret signs everyone out - for MySQL instead, swap the image and the URL scheme:
AUTHWALL_DB: mysql://authwall:authwall@mysql/authwall(the getting-started walkthrough is a full MySQL stack)
The full setup
Everything from the progression above in one file: a seeded admin, personal access tokens, and WebSockets, on Postgres.
services:
authwall:
image: vbarbarosh/authwall
restart: unless-stopped
environment:
AUTHWALL_PUBLIC_URL: http://localhost:3000
AUTHWALL_UPSTREAM_URL: http://app:8080
AUTHWALL_DB: postgres://authwall:authwall@postgres/authwall
AUTHWALL_SEED: 'admin:change-me:admin@myapp.test'
AUTHWALL_PERSONAL_ACCESS_TOKENS: "true"
AUTHWALL_WEBSOCKETS: "true"
ports:
- 3000:3000
volumes:
- ./data/authwall:/app/data
depends_on:
- postgres
- app
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: authwall
POSTGRES_USER: authwall
POSTGRES_PASSWORD: authwall
volumes:
- ./data/postgres:/var/lib/postgresql/data
app:
image: jmalloc/echo-server
restart: unless-stoppedAUTHWALL_SEEDcreates theadminuser on first boot; sign in withadmin/change-meand change the password from the profile- personal access tokens let
signed-in users mint bearer tokens, and
AUTHWALL_WEBSOCKETSproxies upgrades that authenticate with one (tokens are required for the WebSocket path) - it runs over plain HTTP here; in production set
AUTHWALL_PUBLIC_URLto yourhttps://origin and the session cookie becomesSecureautomatically (see Deployment)
To stop the stack and wipe users, sessions, and the secret:
docker compose down && rm -rf dataGoing further
- Getting started — the same progression as a Docker Compose walkthrough with MySQL.
- Deployment — HTTPS, production databases, health checks, and the production checklist.
- Deployment examples — runnable Compose setups for the direct, reverse-proxy, and sidecar topologies.
Deployment
Getting started gets Authwall running. This page covers what to change when moving from a trial to a real deployment: HTTPS, a durable secret, a production database, logging, and health checks.
For the exact syntax and defaults of every variable mentioned here, see the configuration reference.
Runnable examples
examples/ holds self-contained Docker Compose setups for the
common topologies — Authwall as the entrypoint, behind nginx or Caddy, or as a
sidecar auth checker. Each is runnable with docker compose up and is a good
starting point to copy from. See examples/README.md for
a chooser.
The Docker image
Authwall is published as vbarbarosh/authwall on Docker Hub. The image:
- runs as the non-root
nodeuser; - uses
dumb-initas PID 1 for correct signal handling; - sets
NODE_ENV=production,LISTEN=0.0.0.0,PORT=3000, andAUTHWALL_LOGGER=stdoutby default; - is tagged per release (
:1,:1.12,:1.12.0) as well as:latest.
Pin a version tag in production so deployments are reproducible.
HTTPS and the public URL
Authwall does not terminate TLS itself. In production it runs behind a TLS terminator — a reverse proxy (nginx, Caddy) or a cloud load balancer — that holds the certificate and forwards plain HTTP to Authwall:
client → TLS terminator (HTTPS) → authwall → your appflowchart LR
client -->|HTTPS| tls["TLS terminator"] -->|HTTP| authwall --> app["your app"]Set AUTHWALL_PUBLIC_URL to the externally
visible HTTPS URL. This value builds the links in emails and OAuth redirects,
and — importantly — it drives the default of
AUTHWALL_COOKIE_SECURE: when AUTHWALL_PUBLIC_URL
begins with https://, the session cookie is marked Secure automatically.
AUTHWALL_PUBLIC_URL=https://auth.myapp.testWhen OAuth providers are configured, their redirect URLs must use this same public HTTPS origin — see OAuth providers.
The session secret
Authwall derives its session secret from one root secret, so that value must stay stable across restarts. Resolution order:
AUTHWALL_SECRETif it is set (must be at least 32 characters);- otherwise
/app/data/secret.keyif that file exists; - otherwise a new random secret is generated and written to that file.
For a single host, persisting /app/data (so secret.key survives restarts)
is enough. When secrets are managed by an orchestrator or an external secret
store — or when more than one Authwall instance must share sessions — set
AUTHWALL_SECRET explicitly and identically on every instance.
Generate one with bin/random-secret. Rotating
the secret invalidates all existing sessions and CSRF tokens by design.
Database
Authwall supports SQLite (default), MySQL, and PostgreSQL, selected by
AUTHWALL_DB.
- SQLite — the default when
AUTHWALL_DBis unset. Fine for a single instance; the database file lives under the data directory, which must be on a persistent volume. - MySQL / PostgreSQL — set
AUTHWALL_DBto amysql://orpostgres://URI. Use this when you want managed backups, or when running more than one Authwall instance against shared state.
Pending migrations are applied automatically when Authwall starts — there is no separate migration step to run for a deployed instance. On boot Authwall waits for the database to accept connections before serving traffic.
Cookies
The session cookie is normally fine with defaults, but two cases need attention:
- Subdomains — if users reach Authwall and the app on different subdomains,
set
AUTHWALL_COOKIE_DOMAINto the shared parent domain. - Cross-site —
AUTHWALL_COOKIE_SAMESITE=nonerequiresAUTHWALL_COOKIE_SECURE=true; Authwall refuses to start otherwise.
The cookie lifetime is fixed at 30 days.
Forwarding to the upstream
Requests that are not under /auth are proxied to
AUTHWALL_UPSTREAM_URL — Authwall's single
upstream. For authenticated, non-public requests Authwall adds an X-Auth-User
header so the upstream can identify the user.
AUTHWALL_UPSTREAM_MODE decides how requests
are forwarded: direct when one app sits behind Authwall, or proxy when the
upstream is a reverse proxy that fans out to several domains. Use
AUTHWALL_SET_HEADERS / AUTHWALL_UNSET_HEADERS
to add or strip headers on proxied requests.
Logging
The Docker image defaults to AUTHWALL_LOGGER=stdout,
which is correct for containers — a log collector or docker logs picks it up.
Use daily only when writing to a persistent log directory on disk.
Error reporting
Set AUTHWALL_SENTRY_DSN to send exceptions to Sentry.
Authwall strips cookies, authorization headers, and request bodies, and redacts
OAuth code / state / token parameters before events are sent.
Health checks
GET /auth/health is unauthenticated, returns OK with HTTP 200, and sets an
x-authwall-version response header. Point container, orchestrator, or load
balancer liveness/readiness probes at it.
GET /auth/health → 200 OKRestricting access
By default registration is open — anyone can sign up. To run Authwall as a
gate for a known set of users, configure the
access rules (AUTHWALL_ALLOWED_EMAILS,
AUTHWALL_ALLOWED_DOMAINS, and the deny lists). They apply to every sign-in
flow, including OAuth.
Per-IP rate limiting is on by default; leave it on unless an upstream proxy already throttles requests.
Production checklist
- [ ] Pin a versioned image tag (e.g.
vbarbarosh/authwall:1.12.0). - [ ] Terminate TLS in front of Authwall.
- [ ] Set
AUTHWALL_PUBLIC_URLto the HTTPS URL. - [ ] Persist the data directory, or set
AUTHWALL_SECRETexplicitly. - [ ] Use MySQL or PostgreSQL if you need backups or multiple instances.
- [ ] Confirm
AUTHWALL_COOKIE_SECUREistrue(automatic under HTTPS). - [ ] Configure a real mailer if any email-based flow is enabled.
- [ ] Restrict registration with the access rules if sign-up should not be open.
- [ ] Wire
/auth/healthinto your liveness/readiness probes. - [ ] Set
AUTHWALL_SENTRY_DSNif you want error reporting.
Deployment examples
Each subdirectory is a self-contained, runnable deployment. Pick the one that matches how you want Authwall to sit relative to your app, then:
cd docs/examples/<scenario>
docker compose upAll examples use SQLite (no database service) and jmalloc/echo-server as a
stand-in upstream app, so the X-Auth-User header is visible in the echoed
response. Environment variables are inline in each docker-compose.yaml — there
is no .env file to create.
The three topologies
In every topology Authwall is the entrypoint and has exactly one upstream. The topology is about what that upstream is.
| Scenario | Authwall's upstream | Use when |
|---|---|---|
| direct | The app itself | One app sits behind Authwall |
| proxy | A reverse proxy that routes by domain | Several domains sit behind one Authwall |
| sidecar | — (the reverse proxy serves the app; Authwall only answers an auth check) | You want Authwall out of the data path |
direct client → authwall → app
proxy client → authwall → nginx/caddy → apps
sidecar client → nginx/caddy → app
↑ auth check
authwallflowchart TB
subgraph direct
direction LR
dc[client] --> da[authwall] --> dapp[app]
end
subgraph proxy
direction LR
pc[client] --> pa[authwall] --> prp["nginx/caddy"] --> papps[apps]
end
subgraph sidecar
direction LR
sc[client] --> srp["nginx/caddy"] --> sapp[app]
srp -.auth check.-> sa[authwall]
end
direct ~~~ proxy ~~~ sidecarExamples
authwall-direct/— one app behind Authwall.authwall-proxy-nginx/— several domains, nginx fan-out.authwall-proxy-caddy/— several domains, Caddy fan-out.authwall-sidecar-nginx/— nginxauth_request.authwall-sidecar-caddy/— Caddyforward_auth.
These examples run over plain HTTP for simplicity. For real deployments see ../deployment.md.
Resetting
Each example persists Authwall's data (the SQLite database and the generated
secret) in a ./data directory. To start fresh:
docker compose down
rm -rf data
authwall-direct
Authwall is the entrypoint. Clients connect to Authwall directly; it handles sign-in and proxies authenticated requests to the upstream app.
client → authwall → appflowchart LR
client --> authwall --> appThis is the simplest topology — no reverse proxy involved. It matches the
docker-compose.yaml shipped at the repository root, minus the external
database (this example uses SQLite).
Run it
docker compose upThen open http://localhost:3000, choose Sign up, and create an account.
After signing in you land on the echo-server upstream, which echoes your
request back — look for the X-Auth-User header Authwall added.
What to change for your app
AUTHWALL_UPSTREAM_URL— point it at your own app instead ofapp:8080.AUTHWALL_PUBLIC_URL— set it to the URL users actually reach Authwall on.AUTHWALL_UPSTREAM_MODE— leave itdirectwhile one app sits behind Authwall. Switch toproxyonly when the upstream is a reverse proxy serving several domains (see theauthwall-proxy-nginxexample).
Notes
- Storage is SQLite, kept in
./dataalong with the generated session secret. - This example serves plain HTTP. For HTTPS, terminate TLS at a cloud load
balancer in front of Authwall, and set
AUTHWALL_PUBLIC_URLto thehttps://address.
Configuration files
docker-compose.yaml
services:
# Authwall is the entrypoint. Clients connect to it directly on port 3000,
# and it proxies authenticated requests to the upstream `app` service.
authwall:
image: vbarbarosh/authwall
restart: unless-stopped
environment:
AUTHWALL_PUBLIC_URL: http://localhost:3000
AUTHWALL_UPSTREAM_URL: http://app:8080
ports:
- 3000:3000
volumes:
- ./data:/app/data
depends_on:
- app
# Stand-in upstream app. echo-server echoes each request it receives,
# so you can see the `X-Auth-User` header Authwall adds.
app:
image: jmalloc/echo-server
restart: unless-stopped
authwall-proxy-nginx
Several domains behind a single Authwall. Authwall authenticates every request, then forwards it to an nginx reverse proxy that routes each domain to its own app.
client → authwall → nginx → { apps, notes, echo }flowchart LR
client --> authwall --> nginx
nginx --> apps
nginx --> notes
nginx --> echoAuthwall always has exactly one upstream. Here that upstream is the nginx
router, and AUTHWALL_UPSTREAM_MODE=proxy makes Authwall preserve the client's
original Host header so nginx can tell the domains apart.
Set up local hostnames
The example uses three domains, so add them to your hosts file — /etc/hosts
on Linux/macOS, C:\Windows\System32\drivers\etc\hosts on Windows:
127.0.0.1 apps.mydomain.test
127.0.0.1 notes.mydomain.test
127.0.0.1 echo.mydomain.testRun it
docker compose upOpen any of the three domains on port 3000:
Choose Sign up on any one of them and create an account. Because the
session cookie is scoped to mydomain.test (AUTHWALL_COOKIE_DOMAIN), that one
sign-in is valid across all three domains.
Each app is a jmalloc/echo-server that echoes the request back. The echoed
Host header shows which domain reached which app — proof that Authwall
preserved the Host and nginx routed on it.
How it works
authwallis the only published service (port 3000); all three domains resolve to it.AUTHWALL_UPSTREAM_MODE=proxytells Authwall to keep the client's originalHostheader when forwarding to its single upstream,http://nginx.nginx.confis the router: amapturns the requestHostinto the matching upstream, and a singleserverblock proxies to it — or returns404for an unknown domain. It is the whole nginx config, mounted over/etc/nginx/nginx.conf.AUTHWALL_COOKIE_DOMAIN=mydomain.testshares the session across the subdomains, so users sign in once.
What to change for your app
- Replace the
apps/notes/echoservices with your own apps. - Edit the
map $host $upstreamentries innginx.confto match your domains. - For production, give the domains real DNS records, and terminate TLS at a
load balancer in front of Authwall; set
AUTHWALL_PUBLIC_URLto thehttps://address.
Configuration files
docker-compose.yaml
services:
# Authwall is the entrypoint and the only published service. It authenticates
# every request, then forwards it to its single upstream — the nginx router.
# AUTHWALL_UPSTREAM_MODE=proxy keeps the client's original Host header so nginx
# can route by domain. AUTHWALL_COOKIE_DOMAIN shares the session across the
# subdomains, so one sign-in covers all of them.
authwall:
image: vbarbarosh/authwall
restart: unless-stopped
environment:
AUTHWALL_PUBLIC_URL: http://apps.mydomain.test:3000
AUTHWALL_UPSTREAM_URL: http://nginx
AUTHWALL_UPSTREAM_MODE: proxy
AUTHWALL_COOKIE_DOMAIN: mydomain.test
ports:
- 3000:3000
volumes:
- ./data:/app/data
depends_on:
- nginx
# Reverse proxy behind Authwall. Routes each domain to its own app by Host.
nginx:
image: nginx:alpine
restart: unless-stopped
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- apps
- notes
- echo
# Stand-in upstream apps, one per domain. echo-server echoes each request,
# so the response shows which Host reached which app.
apps:
image: jmalloc/echo-server
restart: unless-stopped
notes:
image: jmalloc/echo-server
restart: unless-stopped
echo:
image: jmalloc/echo-server
restart: unless-stoppednginx.conf
events {}
http {
# Docker internal DNS
resolver 127.0.0.11 valid=10s;
map $host $upstream {
default "";
apps.mydomain.test http://apps:8080;
echo.mydomain.test http://echo:8080;
notes.mydomain.test http://notes:8080;
}
server {
listen 80;
server_name _;
location / {
if ($upstream = "") {
return 404;
}
proxy_pass $upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
}
authwall-proxy-caddy
Several domains behind a single Authwall. Authwall authenticates every request, then forwards it to a Caddy reverse proxy that routes each domain to its own app.
client → authwall → caddy → { apps, notes, echo }flowchart LR
client --> authwall --> caddy
caddy --> apps
caddy --> notes
caddy --> echoAuthwall always has exactly one upstream. Here that upstream is the Caddy
router, and AUTHWALL_UPSTREAM_MODE=proxy makes Authwall preserve the client's
original Host header so Caddy can tell the domains apart.
Set up local hostnames
The example uses three domains, so add them to your hosts file — /etc/hosts
on Linux/macOS, C:\Windows\System32\drivers\etc\hosts on Windows:
127.0.0.1 apps.mydomain.test
127.0.0.1 notes.mydomain.test
127.0.0.1 echo.mydomain.testRun it
docker compose upOpen any of the three domains on port 3000:
Choose Sign up on any one of them and create an account. Because the
session cookie is scoped to mydomain.test (AUTHWALL_COOKIE_DOMAIN), that one
sign-in is valid across all three domains.
Each app is a jmalloc/echo-server that echoes the request back. The echoed
Host header shows which domain reached which app — proof that Authwall
preserved the Host and Caddy routed on it.
How it works
authwallis the only published service (port 3000); all three domains resolve to it.AUTHWALL_UPSTREAM_MODE=proxytells Authwall to keep the client's originalHostheader when forwarding to its single upstream,http://caddy.- The
Caddyfilelistens on plain HTTP and uses ahostmatcher per domain, routing each to its app (apps,notes,echo). AUTHWALL_COOKIE_DOMAIN=mydomain.testshares the session across the subdomains, so users sign in once.
What to change for your app
- Replace the
apps/notes/echoservices with your own apps. - Edit the
hostmatchers andreverse_proxytargets in theCaddyfileto match your domains. - For production, give the domains real DNS records, and terminate TLS at a
load balancer in front of Authwall; set
AUTHWALL_PUBLIC_URLto thehttps://address.
Configuration files
docker-compose.yaml
services:
# Authwall is the entrypoint and the only published service. It authenticates
# every request, then forwards it to its single upstream — the Caddy router.
# AUTHWALL_UPSTREAM_MODE=proxy keeps the client's original Host header so Caddy
# can route by domain. AUTHWALL_COOKIE_DOMAIN shares the session across the
# subdomains, so one sign-in covers all of them.
authwall:
image: vbarbarosh/authwall
restart: unless-stopped
environment:
AUTHWALL_PUBLIC_URL: http://apps.mydomain.test:3000
AUTHWALL_UPSTREAM_URL: http://caddy
AUTHWALL_UPSTREAM_MODE: proxy
AUTHWALL_COOKIE_DOMAIN: mydomain.test
ports:
- 3000:3000
volumes:
- ./data:/app/data
depends_on:
- caddy
# Reverse proxy behind Authwall. Routes each domain to its own app by Host.
caddy:
image: caddy:alpine
restart: unless-stopped
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
depends_on:
- apps
- notes
- echo
# Stand-in upstream apps, one per domain. echo-server echoes each request,
# so the response shows which Host reached which app.
apps:
image: jmalloc/echo-server
restart: unless-stopped
notes:
image: jmalloc/echo-server
restart: unless-stopped
echo:
image: jmalloc/echo-server
restart: unless-stoppedCaddyfile
# Reverse proxy behind Authwall.
#
# Authwall has already authenticated the request and forwarded it here with the
# client's original Host header preserved (AUTHWALL_UPSTREAM_MODE=proxy). Caddy
# matches that Host and sends each domain to its own app.
#
# This Caddy sits behind Authwall and never faces clients directly, so it
# listens on plain HTTP port 80 and needs no TLS.
:80 {
@apps host apps.mydomain.test
handle @apps {
reverse_proxy apps:8080
}
@notes host notes.mydomain.test
handle @notes {
reverse_proxy notes:8080
}
@echo host echo.mydomain.test
handle @echo {
reverse_proxy echo:8080
}
}
authwall-sidecar-nginx
Several domains behind nginx, with Authwall as a sidecar auth checker. nginx
serves each domain's app directly and consults Authwall only for an auth
decision, using nginx's auth_request module. Authwall is not in the data
path.
client → nginx → { apps, notes, echo }
↑ auth check
authwallflowchart LR
client --> nginx
nginx --> apps
nginx --> notes
nginx --> echo
nginx -.auth check.-> authwallUse this topology when you want Authwall out of the request path — for performance or isolation — and only need it to answer "is this user signed in?" on each request.
Set up local hostnames
The example uses three domains, so add them to your hosts file — /etc/hosts
on Linux/macOS, C:\Windows\System32\drivers\etc\hosts on Windows:
127.0.0.1 apps.mydomain.test
127.0.0.1 notes.mydomain.test
127.0.0.1 echo.mydomain.testRun it
docker compose upThen open any of the three domains on port 3000:
You are redirected to Authwall's sign-in page, served under /auth on the same
domain; sign up, and you are sent back to the app you started from. Because the
session cookie is scoped to mydomain.test (AUTHWALL_COOKIE_DOMAIN), that one
sign-in is valid across all three domains. Each echo-server response shows the
X-Auth-User header nginx attached from Authwall's auth decision.
How it works
nginx.conf is the whole nginx config, mounted over /etc/nginx/nginx.conf.
A single *.mydomain.test server serves two things:
/auth/…is proxied to Authwall — its sign-in UI, OAuth callbacks, and the/auth/sidecarendpoint.- everything else is the protected app. For each request nginx makes an
internal
auth_requestto/auth/sidecar; on 200 it copiesX-Auth-Userand proxies to the app picked from the requestHostby amap; on 401 it redirects the browser to the sign-in page with areturnURL so the user comes back.
AUTHWALL_COOKIE_DOMAIN=mydomain.test scopes the session cookie to every
*.mydomain.test domain, so one sign-in covers all of them.
Security notes
X-Auth-Useris set by nginx from the auth subrequest's response, which overrides any value a client tried to send. The app can trust it.- The apps are reachable only through nginx — do not publish the
apps/notes/echoservices directly, or requests would bypass the auth check.
What to change for your app
- Replace the
apps/notes/echoservices with your own apps. - Edit the
map $host $upstreamentries innginx.confto match your domains. - The
/auth/path prefix is reserved for Authwall on every domain — if an app has its own routes under/auth/, route them more specifically. - For HTTPS, terminate TLS in front of nginx and set
AUTHWALL_PUBLIC_URLaccordingly.
Configuration files
docker-compose.yaml
services:
# nginx is the entrypoint and the only published service. It serves each
# app's domain directly — gating every request with an `auth_request` to
# Authwall's /auth/sidecar endpoint — and serves the Authwall UI under /auth
# on every domain.
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- 3000:80
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- authwall
- apps
- notes
- echo
# Authwall runs beside nginx as an auth checker, reached under /auth on every
# domain. It is not in the data path and never proxies, so it needs no
# AUTHWALL_UPSTREAM_URL. AUTHWALL_COOKIE_DOMAIN shares the session across every
# *.mydomain.test host, so one sign-in covers them all.
authwall:
image: vbarbarosh/authwall
restart: unless-stopped
environment:
AUTHWALL_PUBLIC_URL: http://apps.mydomain.test:3000
AUTHWALL_COOKIE_DOMAIN: mydomain.test
volumes:
- ./data:/app/data
# Stand-in upstream apps, one per domain. echo-server echoes each request,
# so the response shows which Host reached which app.
apps:
image: jmalloc/echo-server
restart: unless-stopped
notes:
image: jmalloc/echo-server
restart: unless-stopped
echo:
image: jmalloc/echo-server
restart: unless-stoppednginx.conf
events {}
http {
# Docker internal DNS
resolver 127.0.0.11 valid=10s;
map $host $upstream {
default "";
apps.mydomain.test http://apps:8080;
echo.mydomain.test http://echo:8080;
notes.mydomain.test http://notes:8080;
}
server {
listen 80;
server_name *.mydomain.test;
# ── Authwall UI (sign-in, OAuth callbacks, sessions, etc.) ──
location /auth/ {
proxy_pass http://authwall:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_http_version 1.1;
}
# ── Protected apps ──
location / {
# ── Auth check ──
auth_request /_auth;
auth_request_set $auth_user $upstream_http_x_auth_user;
# On 401 → redirect to sign-in
error_page 401 = @authwall_signin;
# Unknown host → 404
if ($upstream = "") {
return 404;
}
# ── Proxy to upstream app ──
proxy_pass $upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Auth-User $auth_user;
proxy_set_header Connection "";
proxy_http_version 1.1;
}
# ── Auth subrequest ──
location = /_auth {
internal;
proxy_pass http://authwall:3000/auth/sidecar;
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Original-URI $scheme://$host$request_uri;
proxy_set_header X-Original-Method $request_method;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
}
# ── Redirect to login ──
location @authwall_signin {
# Relative $uri: sign-in is on this same domain, so no scheme/host
# is needed — and a relative path avoids query-encoding pitfalls.
return 302 /auth/sign-in?return=$uri;
}
}
}
authwall-sidecar-caddy
Several domains behind Caddy, with Authwall as a sidecar auth checker. Caddy
serves each domain's app directly and consults Authwall only for an auth
decision, using Caddy's forward_auth directive. Authwall is not in the
data path.
client → caddy → { apps, notes, echo }
↑ auth check
authwallflowchart LR
client --> caddy
caddy --> apps
caddy --> notes
caddy --> echo
caddy -.auth check.-> authwallUse this topology when you want Authwall out of the request path — for performance or isolation — and only need it to answer "is this user signed in?" on each request.
Set up local hostnames
The example uses three domains, so add them to your hosts file — /etc/hosts
on Linux/macOS, C:\Windows\System32\drivers\etc\hosts on Windows:
127.0.0.1 apps.mydomain.test
127.0.0.1 notes.mydomain.test
127.0.0.1 echo.mydomain.testRun it
docker compose upThen open any of the three domains on port 3000:
You are redirected to Authwall's sign-in page, served under /auth on the same
domain; sign up, and you are sent back to the app you started from. Because the
session cookie is scoped to mydomain.test (AUTHWALL_COOKIE_DOMAIN), that one
sign-in is valid across all three domains. Each echo-server response shows the
X-Auth-User header Caddy attached from Authwall's auth decision.
How it works
Caddyfile is mounted over /etc/caddy/Caddyfile. A (gate) snippet holds the
shared logic, and each domain is a site block that imports it with its own
backend app:
/auth/*is proxied to Authwall — its sign-in UI, OAuth callbacks, and the/auth/sidecarendpoint.- everything else is the protected app.
forward_authmakes a subrequest to Authwall's/auth/sidecar; on 200 Caddy copiesX-Auth-Userand proxies to the app; on 401 it redirects the browser to the sign-in page with a relativereturnpath so the user comes back.
AUTHWALL_COOKIE_DOMAIN=mydomain.test scopes the session cookie to every
*.mydomain.test domain, so one sign-in covers all of them.
Security notes
X-Auth-Useris taken from the auth subrequest's trusted response — the app can rely on it.- The apps are reachable only through Caddy — do not publish the
apps/notes/echoservices directly, or requests would bypass the auth check.
What to change for your app
- Replace the
apps/notes/echoservices with your own apps. - Add or edit the per-domain site blocks in the
Caddyfileto match your domains. - The
/auth/path prefix is reserved for Authwall on every domain — if an app has its own routes under/auth/, route them more specifically. - For HTTPS, give the domains real names and drop the
http://prefix — Caddy then obtains certificates automatically; setAUTHWALL_PUBLIC_URLto match.
Configuration files
docker-compose.yaml
services:
# Caddy is the entrypoint and the only published service. It serves each
# app's domain directly — gating every request with a `forward_auth` to
# Authwall's /auth/sidecar endpoint — and serves the Authwall UI under /auth
# on every domain.
caddy:
image: caddy:alpine
restart: unless-stopped
ports:
- 3000:80
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
depends_on:
- authwall
- apps
- notes
- echo
# Authwall runs beside Caddy as an auth checker, reached under /auth on every
# domain. It is not in the data path and never proxies, so it needs no
# AUTHWALL_UPSTREAM_URL. AUTHWALL_COOKIE_DOMAIN shares the session across every
# *.mydomain.test host, so one sign-in covers them all.
authwall:
image: vbarbarosh/authwall
restart: unless-stopped
environment:
AUTHWALL_PUBLIC_URL: http://apps.mydomain.test:3000
AUTHWALL_COOKIE_DOMAIN: mydomain.test
volumes:
- ./data:/app/data
# Stand-in upstream apps, one per domain. echo-server echoes each request,
# so the response shows which Host reached which app.
apps:
image: jmalloc/echo-server
restart: unless-stopped
notes:
image: jmalloc/echo-server
restart: unless-stopped
echo:
image: jmalloc/echo-server
restart: unless-stoppedCaddyfile
# Authwall as a sidecar auth checker, several domains behind Caddy.
#
# Caddy serves each domain's app directly and consults Authwall only for an
# auth decision (forward_auth → /auth/sidecar). Authwall is not in the data
# path; its UI is served under /auth on every domain.
#
# The (gate) snippet holds the shared logic; each domain is a site block that
# imports it with its own backend app.
(gate) {
# Authwall's UI and endpoints.
handle /auth/* {
reverse_proxy authwall:3000
}
# The protected app, gated by an auth subrequest to Authwall.
handle {
forward_auth authwall:3000 {
uri /auth/sidecar
copy_headers X-Auth-User
# No session → send the browser to the sign-in page.
@unauthorized status 401
handle_response @unauthorized {
redir /auth/sign-in?return={http.request.uri.path}
}
}
reverse_proxy {args[0]}
}
}
http://apps.mydomain.test {
import gate apps:8080
}
http://notes.mydomain.test {
import gate notes:8080
}
http://echo.mydomain.test {
import gate echo:8080
}
Sign-in flows
A sign-in flow is one way a user can authenticate with Authwall. Three families are available:
| Flow | How the user signs in |
|---|---|
| Password | Username or email address, plus a password |
| Magic link / code | A one-time link or code delivered by email |
| OAuth | An external provider — Google, GitHub, Microsoft, Facebook, X, Discord |
Several flows can be enabled at once; the sign-in page shows every flow that is
turned on. Which flows are active is decided at startup from
AUTHWALL_FLOWS and the supporting variables
described below.
Choosing flows: AUTHWALL_FLOWS
AUTHWALL_FLOWS is the final step of configuration. Mailer, OAuth credentials,
password options, and magic-link mode are all resolved first; AUTHWALL_FLOWS
then selects among the flows those settings made available.
It accepts either auto (the default) or a comma-separated list drawn from:
username email magic_link magic_code magic_link_and_code
google github microsoft facebook twitter discordThere is no password value — password sign-in is requested per identifier
with username and/or email.
auto mode
In auto mode every flow whose prerequisites are already in place is enabled —
with one important exception: if any OAuth provider is configured, the
password and magic-link flows are switched off in auto mode. The assumption
is that once you wire up an external identity provider, you want sign-in to go
through it exclusively.
To run an OAuth provider alongside password or magic-link sign-in, list the
flows explicitly instead of relying on auto:
# Google plus username/password — both offered
AUTHWALL_FLOWS=username,googleExplicit lists
When AUTHWALL_FLOWS names flows explicitly, each one must already be fully
configured. If a listed flow is missing its prerequisites — for example
email without a mailer, or github without GitHub credentials — Authwall
refuses to start with an error naming the problem. This is deliberate: it
prevents a flow you asked for from silently not appearing.
Password
Sign-in with a password, identified by a username, an email address, or both.
username— sign in with a username. No mailer required.email— sign in with an email address. Requires a configured mailer (so the address can be confirmed and recovered).
In auto mode both are enabled when no OAuth provider is configured;
email additionally needs a mailer.
Pages and routes:
| Path | Purpose |
|---|---|
/auth/sign-in |
Sign-in page |
/auth/sign-up |
Registration page — registration is open; the first visitor creates the first account |
/auth/password-reset |
Request a password-reset email |
/auth/password-reset/confirm |
Set a new password from the reset link |
/auth/change-password |
Change the password of the signed-in account (from the profile) |
Password length and hashing are governed by
AUTHWALL_PASSWORD_MIN and AUTHWALL_BCRYPT_ROUNDS.
Password reset requires a mailer regardless of which identifier is used.
# Username + password only — the zero-config default
AUTHWALL_FLOWS=username
# Username and email, both with passwords (needs a mailer)
AUTHWALL_FLOWS=username,emailMagic link / code
Passwordless sign-in: the user enters their email address and receives a one-time link, a one-time code, or both. A magic link is also a sign-up path — a first-time address creates an account.
This flow always requires a configured mailer.
Which channel users get is set by
AUTHWALL_MAGIC_LINK:
link— email contains only a clickable link.code— email contains only a code the user types into the browser.link_and_code— email contains both (the default inauto).off/disabled— magic-link sign-in is disabled.
In AUTHWALL_FLOWS, the channel is requested with magic_link, magic_code,
or magic_link_and_code (shorthand for both). The requested channel must be
compatible with AUTHWALL_MAGIC_LINK — asking for magic_code while
AUTHWALL_MAGIC_LINK=link is a startup error.
Pages and routes:
| Path | Purpose |
|---|---|
/auth/magic-link |
Request a magic link or code |
/auth/magic-link/confirm |
Confirm a link or submit a code |
/auth/magic-link/sent |
"Check your email" notice |
# Magic code only (also set AUTHWALL_MAGIC_LINK=code)
AUTHWALL_FLOWS=magic_code
AUTHWALL_MAGIC_LINK=codeOAuth
Sign-in delegated to an external provider — Google, GitHub, Microsoft, Facebook, X, or Discord. OAuth registration is open by default: anyone with an account at the provider can sign in, and an Authwall account is created on first use.
Each provider is requested by name in AUTHWALL_FLOWS (google, github,
microsoft, facebook, twitter, discord) and needs its three
*_CLIENT_ID / *_CLIENT_SECRET / *_REDIRECT_URL variables.
Provider setup — where to register the app, redirect URLs, scopes, and per-provider quirks — is covered in detail in OAuth providers.
Combining flows
Flows are additive. A user with multiple identities (say a password and a linked Google account) can sign in through any of them, and from the profile page can connect or disconnect providers — except the last remaining sign-in method, which cannot be removed.
# Password, magic link, and three OAuth providers, all offered
AUTHWALL_FLOWS=username,email,magic_link,google,github,microsoftRestricting who can sign in
All flows funnel through the same access rules
(AUTHWALL_ALLOWED_EMAILS, AUTHWALL_ALLOWED_DOMAINS, and the deny lists).
Use them to turn open registration into an allowlist regardless of which flow a
user picks.
Rate limiting
When AUTHWALL_RATE_LIMITING is enabled
(the default), the entry points of these flows are rate-limited per client IP:
- Sign-in — 10 requests per 15 minutes.
- Sign-up — 5 requests per hour.
- Password reset — 5 requests per hour.
- Magic-link request — 5 requests per hour.
Email confirmation
Independently of the flow used to sign in, Authwall can require a user to
confirm an email address before reaching the upstream app. See
AUTHWALL_CONFIRM_EMAIL.
OAuth providers
Authwall can sign users in through six OAuth providers: Google, GitHub, Microsoft, Facebook, X (formerly Twitter), and Discord.
This page covers the provider-side setup — where to register an application and what to copy back. For the environment variables themselves, see the configuration reference.
How OAuth sign-in works
Each provider exposes three routes:
| Route | Method | Purpose |
|---|---|---|
/auth/<provider> |
GET | Start sign-in (or sign-up) |
/auth/<provider>/callback |
GET | Where the provider sends the user back |
/auth/<provider>/disconnect |
POST | Unlink the provider from the signed-in account |
<provider> is one of google, github, microsoft, facebook, twitter,
discord.
A few behaviors are the same for every provider:
- PKCE — Authwall uses the authorization-code flow with PKCE (
S256) and a signedstatevalue. You do not configure anything for this. - Sign in vs. connect — visiting
/auth/<provider>signs the user in, creating an account on first use. From the profile page a signed-in user can instead connect a provider to their existing account; that path is rejected if the provider account is already linked to a different user. - Verified emails — when a provider reports a verified email address, Authwall adds it as an email identity on the account (unless another account already owns it). Unverified emails are ignored.
- Access rules —
AUTHWALL_ALLOWED_EMAILS/AUTHWALL_ALLOWED_DOMAINS/ the deny lists are enforced against those verified emails. If any access rule is configured, OAuth sign-in additionally requires the provider to report at least one verified email — otherwise sign-in fails with "A verified email is required". - Disconnect protection — a provider cannot be disconnected if it is the account's only remaining sign-in method.
Enabling a provider
Each provider needs three environment variables, all required together:
AUTHWALL_<PROVIDER>_CLIENT_ID
AUTHWALL_<PROVIDER>_CLIENT_SECRET
AUTHWALL_<PROVIDER>_REDIRECT_URL- With
AUTHWALL_FLOWS=auto(the default), a provider turns on automatically once all three of its variables are set. - If only some of the three are set, Authwall logs a warning and leaves the provider disabled.
- If
AUTHWALL_FLOWSnames the provider explicitly but the three variables are not all set, Authwall refuses to start.
The redirect URL must point back at Authwall's callback route and must exactly match what you register with the provider:
<AUTHWALL_PUBLIC_URL>/auth/<provider>/callbackFor example, with AUTHWALL_PUBLIC_URL=https://myapp.test, Google's redirect
URL is https://myapp.test/auth/google/callback. For local development it is
typically http://localhost:3000/auth/google/callback.
- Console: Google Cloud Console → APIs & Services → Credentials.
- Create: an OAuth client ID of type Web application.
- Authorized redirect URI:
<AUTHWALL_PUBLIC_URL>/auth/google/callback. - Scopes requested:
openid email profile. - Copy back: the client ID and client secret.
An email is treated as verified only when Google reports email_verified for
it.
AUTHWALL_GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com
AUTHWALL_GOOGLE_CLIENT_SECRET=GOCSPX-...
AUTHWALL_GOOGLE_REDIRECT_URL=https://myapp.test/auth/google/callbackGitHub
- Console: GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
- Authorization callback URL:
<AUTHWALL_PUBLIC_URL>/auth/github/callback. - Scopes requested:
user:email. - Copy back: the client ID, and a generated client secret.
GitHub may report several addresses. Authwall keeps every verified address and adds the primary one first.
AUTHWALL_GITHUB_CLIENT_ID=Iv1.abcdef1234567890
AUTHWALL_GITHUB_CLIENT_SECRET=ghs_...
AUTHWALL_GITHUB_REDIRECT_URL=https://myapp.test/auth/github/callbackMicrosoft
- Console: Microsoft Entra admin center → Identity → Applications → App registrations.
- Create: a new registration; under Authentication add a Web platform.
- Redirect URI:
<AUTHWALL_PUBLIC_URL>/auth/microsoft/callback. - Client secret: create one under Certificates & secrets and copy the secret value (not the secret ID).
- Scopes requested:
openid email profile.
Authwall uses the common authority, so both personal Microsoft accounts and
work/school (Entra) accounts can sign in.
AUTHWALL_MICROSOFT_CLIENT_ID=00000000-0000-0000-0000-000000000000
AUTHWALL_MICROSOFT_CLIENT_SECRET=...
AUTHWALL_MICROSOFT_REDIRECT_URL=https://myapp.test/auth/microsoft/callback- Console: Meta for Developers → My Apps → your app → add the Facebook Login product.
- Valid OAuth Redirect URI:
<AUTHWALL_PUBLIC_URL>/auth/facebook/callback(Facebook Login → Settings). - Scopes requested:
email. - Copy back: the App ID and App Secret (Settings → Basic).
Facebook returns an email address only if the user grants the email
permission; accounts that decline it, or that have no email on file, sign in
without an email identity.
AUTHWALL_FACEBOOK_CLIENT_ID=1234567890123456
AUTHWALL_FACEBOOK_CLIENT_SECRET=...
AUTHWALL_FACEBOOK_REDIRECT_URL=https://myapp.test/auth/facebook/callbackX (formerly Twitter)
The environment variables keep their TWITTER names for compatibility, even
though the product is now called X.
- Console: X Developer Portal → Projects & Apps → your app → User authentication settings.
- App type: Web App / Confidential client — Authwall authenticates the token request with HTTP Basic credentials, which X requires for confidential clients.
- Callback URI:
<AUTHWALL_PUBLIC_URL>/auth/twitter/callback. - Scopes requested:
tweet.read users.read users.email. - Copy back: the OAuth 2.0 Client ID and Client Secret.
The users.email scope is what lets Authwall read the account's confirmed
email; without it the user signs in without an email identity.
AUTHWALL_TWITTER_CLIENT_ID=...
AUTHWALL_TWITTER_CLIENT_SECRET=...
AUTHWALL_TWITTER_REDIRECT_URL=https://myapp.test/auth/twitter/callbackDiscord
- Console: Discord Developer Portal → Applications → your application → OAuth2.
- Redirect: add
<AUTHWALL_PUBLIC_URL>/auth/discord/callbackunder Redirects. - Scopes requested:
identify email. - Copy back: the Client ID and Client Secret.
An email is treated as verified only when the Discord account itself is verified.
AUTHWALL_DISCORD_CLIENT_ID=1234567890123456789
AUTHWALL_DISCORD_CLIENT_SECRET=...
AUTHWALL_DISCORD_REDIRECT_URL=https://myapp.test/auth/discord/callbackLimiting OAuth sign-in to specific users
OAuth registration is open by default — anyone with an account at the provider can sign in. To restrict access, combine a provider with the access rules. For example, allow only two named addresses to sign in with Google:
AUTHWALL_ALLOWED_EMAILS=alice@example.com,bob@example.comBecause access rules are checked against the provider's verified emails, this also means a provider that reports no verified email cannot be used to sign in while any access rule is active.
Troubleshooting
- Provider not offered on the sign-in page — one of the three variables is missing. Authwall logs a warning naming the provider at startup.
- "redirect_uri mismatch" from the provider — the URL registered with the
provider does not exactly match
AUTHWALL_<PROVIDER>_REDIRECT_URL(scheme, host, port, and path must all match). - "Invalid OAuth state" — the sign-in was not completed in the same browser session it started in, or the session cookie was lost. Check cookie settings when Authwall runs behind another proxy.
- "A verified email is required" — an access rule is configured but the provider returned no verified email for this account.
Emails
Authwall sends transactional emails — sign-in links, confirmation codes, password resets, and security notifications. Every message is rendered from a template file shipped in the repository, so the wording can be customized without touching code.
When email is sent
Email requires a configured mailer. Set one up with
AUTHWALL_MAILER and a provider (Resend, Mailjet,
or Amazon SES). With no mailer configured, Authwall uses a fake mailer that
silently drops every message — fine for local development, but it means
confirmation and password-reset emails never arrive, so it is not safe for
production.
Authwall only sends to verified email addresses.
Template files
Templates live in design/emails/ as plain-text .txt files — one file per
message. In the published Docker image they are at /app/design/emails/.
Template format
Each file is a Subject: header, a blank line, then the body:
Subject: Your sign-in link
Hi {{display_name}},
Use the link or code below to sign in. Both expire in 10 minutes.
Sign in:
{{link}}
— Authwall- The first line must be
Subject: .... - A blank line separates the subject from the body.
{{placeholder}}markers are replaced at send time (see the reference below).- If
display_nameis empty, the renderer tidiesHi ,down toHi,.
Rendering fails if a template references a placeholder that Authwall does not supply for that message. When editing a template, only use placeholders that already appear in the shipped version of that file.
Templates are plain text; there is currently no HTML version.
Template catalog
| Template file | Sent when |
|---|---|
welcome.txt |
A new account is created (no email confirmation needed) |
welcome-and-confirm-email.txt |
New account that must confirm its email |
confirm-email.txt |
A standalone request to confirm an email address |
email-change-request.txt |
A user requests changing their email — sent to the new address |
email-changed.txt |
Notice sent to the old address that the email is being changed |
magic-link.txt |
A passwordless sign-in link/code is requested |
password-reset.txt |
A password reset is requested |
new-sign-in.txt |
A new sign-in to the account is detected |
password-changed-from-profile.txt |
The password was changed from the profile page |
password-changed-via-reset-link.txt |
The password was changed through a reset link |
<provider>-connected.txt |
An OAuth provider was linked to the account |
<provider>-disconnected.txt |
An OAuth provider was unlinked from the account |
<provider> is one of google, github, microsoft, facebook, twitter,
discord — twelve files in total.
Link / code variants
The magic-link, confirm-email, and welcome-and-confirm templates ship in three
forms, and Authwall picks one to match the configured delivery channel
(AUTHWALL_MAGIC_LINK /
AUTHWALL_CONFIRM_EMAIL):
| Variant | Used when the channel is | Example |
|---|---|---|
base (name.txt) |
both a link and a code | magic-link.txt |
name-without-code.txt |
link only | magic-link-without-code.txt |
name-without-link.txt |
code only | magic-link-without-link.txt |
Edit all the relevant variants when customizing one of these messages.
Placeholder reference
Not every placeholder appears in every template; each file uses the subset relevant to its message.
| Placeholder | Meaning |
|---|---|
{{display_name}} |
The recipient's display name |
{{link}} |
A magic-link sign-in URL |
{{code}} |
A one-time code the user types into a page |
{{confirm_link}} |
An email-confirmation URL |
{{reset_link}} |
A password-reset URL |
{{sign_in_link}} |
The sign-in page URL |
{{sessions_link}} |
The active-sessions page URL |
{{email}} |
An email address relevant to the message |
{{new_email}} |
The requested new email address (email change) |
{{expires_minutes}} |
Minutes until the link or code expires |
{{date}} |
A formatted timestamp |
{{ip}} |
The client IP address |
{{ua}} |
The client browser / user-agent |
Customizing the templates
Editing a template is just editing its .txt file — keep the Subject: header
and the blank line, and reuse only the placeholders already present.
- From source: edit the files under
design/emails/directly. - With the published Docker image: the templates are baked into the image
at
/app/design/emails/. To override them, either bind-mount a directory of customized templates over/app/design/emails, or build a derived image thatCOPYs your versions over the originals.
Restart Authwall after changing templates.
CLI tools
The bin/ directory holds small command-line tools for running, building, and
operating Authwall. They fall into four groups.
Most tools assume a source checkout with dependencies installed (npm install)
and are run from the repository root, e.g. bin/activity-summary day.
Running and development
| Command | What it does |
|---|---|
bin/run |
Start Authwall (npm start). |
bin/watch |
Start in watch mode — restarts on file changes, loads .env (npm run watch). |
bin/test |
Run the full test suite: unit, API, and end-to-end (npm run test). |
Database migrations
| Command | What it does |
|---|---|
bin/migrate |
Apply all pending migrations. |
bin/migrate-make <name> |
Create a new migration file. |
bin/migrate-rollback |
Roll back the most recent migration batch. |
These wrap the Knex CLI for development work against the local SQLite database.
When the server starts, it applies pending migrations automatically (see Getting started). You do not normally need
bin/migratefor a deployed instance — it is a development convenience.
Secrets and hashing
| Command | What it does |
|---|---|
bin/random-secret |
Print a random 32-character secret, suitable for AUTHWALL_SECRET. |
bin/bcrypt <password> |
Print a bcrypt hash of <password>, using the configured cost factor. |
bin/bcrypt is useful for pre-hashing a password for a bootstrap user defined
as password_hash in config/settings.yaml.
bin/random-secret
bin/bcrypt 'correct horse battery staple'Build and release
| Command | What it does |
|---|---|
bin/build |
Build the Docker image, stamping version/revision/source/created build args. Tags vbarbarosh/authwall by default. |
bin/release major|minor|patch |
Bump the version, commit it, and create and push the release tag. |
bin/deploy |
Build and push the Docker image under its version, major, and revision tags. |
bin/release and bin/deploy both refuse to run unless the current branch is
production and the working tree is clean. bin/release only handles the Git
side — it does not run tests or build images; CI runs bin/test and then
bin/deploy after production is pushed.
Operations and reporting
Two read-only tools summarize what an Authwall instance has been doing.
bin/activity-summary
Summarizes authentication events (sign-ins, sign-ups, password changes, and so on) recorded in the database over a time window.
Usage: bin/activity-summary [day|several-days|week|<days>d] [options]
Options:
--days N Summarize the last N days
--since DATE Start date/time, inclusive
--until DATE End date/time, exclusive; defaults to now
--json Print the summary as JSON
-h, --help Show this help
Examples:
bin/activity-summary day
bin/activity-summary several-days
bin/activity-summary week
bin/activity-summary --since 2026-05-01 --until 2026-05-08It reads the database configured by AUTHWALL_DB.
For a Dockerized deployment, run it inside the Authwall container so it sees the
same configuration.
bin/log-summary
Summarizes HTTP requests from the daily log files Authwall writes when
AUTHWALL_LOGGER=daily.
Usage: bin/log-summary [today|yesterday|YYYY-MM-DD|PATH] [options]
Options:
--date YYYY-MM-DD Read app-YYYY-MM-DD.log from the logs directory
--file PATH Read an explicit log file
--logs-dir DIR Directory containing daily app-YYYY-MM-DD.log files
--query Group by full URL path including query string
--group GLOB Collapse matching METHOD path values into one count row
--json Print the summary as JSON
-h, --help Show this help
Examples:
bin/log-summary today
bin/log-summary yesterday
bin/log-summary 2026-04-26 --logs-dir data/authwall/logs
bin/log-summary today --group "GET /t/1024/*"
bin/log-summary --file data/authwall/logs/app-2026-04-26.log--group collapses many similar paths into a single row — for example,
--group "GET /t/*" reports all GET /t/... requests as one count instead of
one row per id.
Security model
This page describes how Authwall protects the app behind it and the accounts it manages. For the environment variables referenced here, see the configuration reference.
The X-Auth-User trust boundary
Authwall's core guarantee: the upstream app can trust the X-Auth-User header.
- Incoming
x-auth-*headers are stripped. Before any request is proxied, Authwall deletes everyx-auth-*header it received from the client. A client cannot smuggleX-Auth-User: adminthrough Authwall. - Authwall sets
X-Auth-Useritself, to the signed-in user's id, only on authenticated requests that are not for a public path. - Unauthenticated requests are never proxied — they are redirected to sign-in — so the app only ever receives requests Authwall has vetted.
- When personal access tokens are
enabled, a valid
Authorization: Bearer ...token is another way to establish the same upstream identity. Authwall validates the token, strips the bearer credential, and forwardsX-Auth-User. - Email-verification enforcement (
AUTHWALL_CONFIRM_EMAIL_REQUIRED) applies to bearer tokens too: a valid token whose owner has no verified email is rejected with403 Email verification required. - When
AUTHWALL_WEBSOCKETSis enabled, the same guarantee extends to upgrades: clients authenticate the upgrade with anAuthorization: Bearerpersonal access token. Authwall strips inboundx-auth-*headers, removes the credential before forwarding, and setsX-Auth-Useritself.
For this guarantee to hold, the app must be reachable only through Authwall. If the app is also exposed directly, a client can reach it without Authwall and forge the header itself.
Sessions and cookies
Sign-in state is kept in a server-side session; the browser holds only an opaque session id.
- The session cookie is
HttpOnly(not readable from JavaScript) andSameSite(laxby default — seeAUTHWALL_COOKIE_SAMESITE). - It is marked
Secureautomatically whenAUTHWALL_PUBLIC_URLishttps://— keep it that way in production. - The session secret is derived from one root secret
(
AUTHWALL_SECRET) via HKDF. The CSRF token is a random per-session value, not derived from the root secret. Rotating the root secret invalidates every session, and the CSRF tokens stored in them. - Sessions are stored in the database, so they survive restarts and are shared across instances that share a database. Signing out, or revoking a session from the profile, deletes it server-side immediately.
Why server-side sessions, not stateless cookies? This is a deliberate choice for an auth proxy. Server-side sessions buy instant revocation: revoking a session from the profile, signing out everywhere on a password reset, and account removal all kill live sessions immediately by deleting their rows. Stateless signed-cookie sessions can't revoke a session before it expires without reintroducing a server-side denylist — which puts the state right back and gives you the worst of both. For a tool whose entire job is gatekeeping, immediate revocation is load-bearing, so sessions stay stateful. That also makes the random per-session CSRF token below the correct design — no app-wide CSRF key needed.
CSRF protection
Every session carries a random CSRF token. State-changing POST endpoints
(password change, account removal, connecting/disconnecting a provider, and so
on) require that token in the request body, and compare it in constant time.
A request without the matching token is rejected. The token is delivered to the
frontend through the GET /auth/status response.
Rate limiting
When AUTHWALL_RATE_LIMITING is enabled
(the default), the sensitive entry points are throttled per client IP:
| Endpoint | Limit |
|---|---|
| Sign-in | 10 requests / 15 minutes |
| Sign-up | 5 requests / hour |
| Password reset | 5 requests / hour |
| Magic-link request | 5 requests / hour |
| Personal access token creation | 5 requests / hour |
| Failed bearer-token validation | 20 requests / 15 minutes |
The bearer-token limiter covers both HTTP requests and WebSocket upgrades that authenticate with a PAT.
Counts are held in memory, so they are not shared between processes and reset on restart. This slows credential stuffing and brute-force attempts; it is not a substitute for an upstream WAF or load-balancer throttling.
Password storage
Passwords are hashed with bcrypt — never stored or logged in clear text.
The cost factor is AUTHWALL_BCRYPT_ROUNDS (default
12). One-time magic-link codes are bcrypt-hashed the same way. New passwords
must meet AUTHWALL_PASSWORD_MIN (default 8).
Access control
Registration is open by default — anyone who can reach the sign-in page can
create an account. To run Authwall as a gate for a known set of users,
configure the access rules: AUTHWALL_ALLOWED_EMAILS,
AUTHWALL_ALLOWED_DOMAINS, and the matching deny lists. When any allow list is
set, the default flips to deny. The rules are enforced on every sign-in flow,
including OAuth (checked against the provider's verified emails).
Optionally, AUTHWALL_CONFIRM_EMAIL_REQUIRED
holds users at an email-confirmation step until they prove control of their
address before any request reaches the app.
Open-redirect protection
Sign-in and similar flows accept a return parameter so the user lands back
where they started. Authwall only honours a return value that is either a
relative path, or an absolute URL on the same host as — or a subdomain of —
AUTHWALL_PUBLIC_URL's hostname. Protocol-relative URLs (//evil.com),
backslash tricks, and encoded leading slashes are rejected, so return cannot
be used to bounce users to an attacker's site.
Audit log
Authentication events — sign-in, sign-out, sign-up, password changes, password
resets, email changes, provider connect/disconnect, session revocation — are
recorded in the database with their outcome (success / failure / no-op). The
bin/activity-summary CLI tool summarises
them over a time window.
Error reporting
When Sentry is enabled, Authwall scrubs events before they
are sent: sendDefaultPii is off, expected user-facing errors are dropped
entirely, Cookie / Authorization / Set-Cookie / X-CSRF-Token headers and
the request body are removed, and query parameters that look like secrets
(token, secret, password, code, state) are replaced with [Filtered].
Running behind a proxy
Authwall sets Express's trust proxy, so req.ip is taken from the
X-Forwarded-For header. That single trust assumption is load-bearing for
several user-visible signals:
- Per-IP rate-limit keys (sign-in, sign-up, PAT creation, bearer-token validation, etc.).
- Last-used IP shown for browser sessions and for personal access tokens.
- Source IP recorded on every row in the
auth_eventsaudit log.
Deploy Authwall behind a reverse proxy or load balancer that overwrites
X-Forwarded-For with the real client connection (nginx's real_ip_header,
Caddy's trusted_proxies, an LB that strips inbound and appends its own, etc.)
— and do not expose Authwall directly to the internet. A directly reachable
instance lets any client send X-Forwarded-For: 1.2.3.4 and have that value
become the recorded IP everywhere above. The "last used from 8.8.8.8" line on a
token row is only meaningful if the operator has actually constrained who can
write that header.
Hardening checklist
- [ ] The upstream app is reachable only through Authwall, never directly.
- [ ] Authwall runs behind a TLS terminator;
AUTHWALL_PUBLIC_URLishttps://. - [ ] The upstream proxy or load balancer overwrites
X-Forwarded-Forfrom the real client (see Running behind a proxy). Otherwise rate-limit keys, last-used IPs, and audit IPs are spoofable. - [ ]
AUTHWALL_SECRETis managed deliberately, ordata/is persisted. - [ ] Rate limiting is left enabled (or handled by an upstream proxy).
- [ ] Registration is restricted with the access rules if sign-up is not meant to be open.
- [ ]
AUTHWALL_BCRYPT_ROUNDSis set appropriately for your hardware. - [ ] Sentry (if used) is on a trusted DSN; redaction is automatic.
Configuration
By default, Authwall does its best to configure flows, mailers, and other integrations from whatever settings are present. But when you explicitly ask for something — a specific mailer, a specific sign-in flow, a specific cookie mode — Authwall refuses to start unless that request is fully satisfied.
This is intentional: it prevents the situation where you believe a setting took effect the way you wanted, but Authwall silently fell back to a different option.
Overview
| Varname | Short description |
|---|---|
LISTEN |
Bind address for the HTTP server |
PORT |
HTTP listen port |
AUTHWALL_SECRET |
Root secret for sessions and CSRF protection |
AUTHWALL_LOGGER |
Log destination |
AUTHWALL_PASSWORD_MIN |
Minimum password length for new passwords |
AUTHWALL_BCRYPT_ROUNDS |
bcrypt cost for new password hashes |
AUTHWALL_RATE_LIMITING |
Enables or disables in-memory rate limiting |
AUTHWALL_SENTRY_DSN |
Sentry DSN for error reporting |
AUTHWALL_SENTRY_ENVIRONMENT |
Sentry environment name |
AUTHWALL_SENTRY_TRACES_SAMPLE_RATE |
Optional Sentry tracing sample rate |
AUTHWALL_PERSONAL_ACCESS_TOKENS |
Enables bearer tokens for API clients |
AUTHWALL_WEBSOCKETS |
Proxies WebSocket connections to the upstream |
AUTHWALL_PUBLIC_URL |
Public base URL used for redirects and generated links |
AUTHWALL_PUBLIC_PATHS |
Public upstream paths that bypass sign-in |
AUTHWALL_OPTIONAL_AUTH_PATHS |
Public paths that receive auth headers when signed in |
AUTHWALL_UPSTREAM_URL |
Upstream application URL |
AUTHWALL_UPSTREAM_MODE |
Upstream proxy behavior mode |
AUTHWALL_SET_HEADERS |
Headers to add to upstream requests |
AUTHWALL_UNSET_HEADERS |
Headers to remove from upstream requests |
AUTHWALL_DB |
Database connection URI |
AUTHWALL_SEED |
Bootstrap users created at startup |
AUTHWALL_COOKIE_DOMAIN |
Session cookie domain |
AUTHWALL_COOKIE_PATH |
Session cookie path |
AUTHWALL_COOKIE_SAMESITE |
SameSite value for the session cookie |
AUTHWALL_COOKIE_SECURE |
Whether session cookies require HTTPS |
AUTHWALL_ALLOWED_EMAILS |
Exact email addresses allowed to sign in |
AUTHWALL_ALLOWED_DOMAINS |
Email domains allowed to sign in |
AUTHWALL_DENIED_EMAILS |
Exact email addresses denied sign-in |
AUTHWALL_DENIED_DOMAINS |
Email domains denied sign-in |
AUTHWALL_CONFIRM_EMAIL_REQUIRED |
Whether a confirmed email is required for access |
AUTHWALL_CONFIRM_EMAIL |
Email-confirmation channel: link, code, or both |
AUTHWALL_MAILER |
Mailer provider selection |
AUTHWALL_RESEND_KEY |
Resend API key |
AUTHWALL_RESEND_FROM |
Resend sender address |
AUTHWALL_MAILJET_KEY |
Mailjet API key |
AUTHWALL_MAILJET_SECRET |
Mailjet API secret |
AUTHWALL_MAILJET_FROM |
Mailjet sender address |
AUTHWALL_SES_KEY |
AWS access key id for SES |
AUTHWALL_SES_SECRET |
AWS secret access key for SES |
AUTHWALL_SES_REGION |
AWS SES region |
AUTHWALL_SES_SESSION_TOKEN |
Optional AWS session token for SES |
AUTHWALL_SES_FROM |
AWS SES sender address |
AUTHWALL_FLOWS |
Enabled sign-in flows |
AUTHWALL_MAGIC_LINK |
Magic-link and magic-code mode |
AUTHWALL_GOOGLE_CLIENT_ID |
Google OAuth client id |
AUTHWALL_GOOGLE_CLIENT_SECRET |
Google OAuth client secret |
AUTHWALL_GOOGLE_REDIRECT_URL |
Google OAuth redirect URL |
AUTHWALL_GITHUB_CLIENT_ID |
GitHub OAuth client id |
AUTHWALL_GITHUB_CLIENT_SECRET |
GitHub OAuth client secret |
AUTHWALL_GITHUB_REDIRECT_URL |
GitHub OAuth redirect URL |
AUTHWALL_FACEBOOK_CLIENT_ID |
Facebook OAuth client id |
AUTHWALL_FACEBOOK_CLIENT_SECRET |
Facebook OAuth client secret |
AUTHWALL_FACEBOOK_REDIRECT_URL |
Facebook OAuth redirect URL |
AUTHWALL_MICROSOFT_CLIENT_ID |
Microsoft OAuth client id |
AUTHWALL_MICROSOFT_CLIENT_SECRET |
Microsoft OAuth client secret |
AUTHWALL_MICROSOFT_REDIRECT_URL |
Microsoft OAuth redirect URL |
AUTHWALL_TWITTER_CLIENT_ID |
X OAuth client id |
AUTHWALL_TWITTER_CLIENT_SECRET |
X OAuth client secret |
AUTHWALL_TWITTER_REDIRECT_URL |
X OAuth redirect URL |
AUTHWALL_DISCORD_CLIENT_ID |
Discord OAuth client id |
AUTHWALL_DISCORD_CLIENT_SECRET |
Discord OAuth client secret |
AUTHWALL_DISCORD_REDIRECT_URL |
Discord OAuth redirect URL |
Server
Where the Authwall HTTP server binds.
LISTEN— bind address. Default:127.0.0.1when running from source; the published Docker image bakes inLISTEN=0.0.0.0so the container is reachable on every interface. Override to a specific address to bind to one interface.PORT— TCP port. Default:3000.
These configure the local listener only; the externally visible URL is set separately via AUTHWALL_PUBLIC_URL.
Example:
LISTEN=0.0.0.0
PORT=8000AUTHWALL_SECRET
Root secret used to derive Authwall's session secret.
- Type: string
- Default: generated automatically and stored in
data/secret.key - Validation: must be at least 32 characters when set
Set this explicitly when secrets are managed by the runtime, orchestrator, or an external secret store.
If it is not set, Authwall loads data/secret.key;
if that file does not exist, Authwall generates a random secret and writes it there.
Rotating this value invalidates existing sessions and CSRF tokens.
If the value (env var or
data/secret.key) is shorter than 32 characters, Authwall refuses to start.
Example:
AUTHWALL_SECRET=$(bin/random-secret)AUTHWALL_LOGGER
Where Authwall writes its log output.
- Type: enum
- Values:
daily,stdout - Default:
dailywhen running from source; the published Docker image bakes inAUTHWALL_LOGGER=stdout
Use daily to write to a date-stamped file under data/logs/, named app-YYYY-MM-DD.log and rotated automatically when the date changes.
Use stdout to write to standard output, which is the right choice for containerized deployments where a process supervisor or log collector picks up stdout.
Example:
AUTHWALL_LOGGER=stdout
Passwords
Controls how Authwall accepts and stores passwords.
AUTHWALL_PASSWORD_MIN— minimum length for new passwords (sign-up, password change, password reset). Type: integer in[4, 32]. Default:8. Existing shorter hashes continue to work on sign-in; the limit is only enforced when a password is set.AUTHWALL_BCRYPT_ROUNDS— bcrypt cost factor for new password hashes and magic-code hashes. Type: integer in[4, 31]. Default:12. Each step roughly doubles hashing time; raising this hardens hashes against offline attacks but slows every sign-in proportionally.
If either value is out of range, Authwall refuses to start.
Example:
AUTHWALL_PASSWORD_MIN=12
AUTHWALL_BCRYPT_ROUNDS=13AUTHWALL_RATE_LIMITING
Toggles Authwall's built-in per-IP rate limiting.
- Type: string flag
- Values:
0to disable; any other value (or unset) leaves it enabled - Default: enabled
When enabled, the following endpoints are rate-limited per client IP:
- Sign-in — 10 requests per 15 minutes.
- Sign-up — 5 requests per hour.
- Password reset — 5 requests per hour.
- Magic-link request — 5 requests per hour.
- Personal access token creation — 5 requests per hour.
- Failed bearer-token validation — 20 requests per 15 minutes (returns
429withRetry-After).
Counts are tracked in memory only, so they do not persist across restarts and are not shared between processes. Disable rate limiting only in environments where requests are throttled by an upstream proxy or load balancer, or in tests where the limits would interfere.
Example:
AUTHWALL_RATE_LIMITING=0
Sentry
Configures Sentry error reporting for the Node/Express process.
AUTHWALL_SENTRY_DSN— enables Sentry when set. Leave unset to disable Sentry.AUTHWALL_SENTRY_ENVIRONMENT— optional environment label, such asproductionorstaging.AUTHWALL_SENTRY_TRACES_SAMPLE_RATE— optional performance tracing sample rate in[0, 1]. Leave unset to disable tracing.
Authwall registers Sentry's Express error handler before its own redirecting error handler, so exceptions are reported while users still receive Authwall's normal error redirect behavior.
Authwall does not enable sendDefaultPii, strips cookies and authorization headers, drops request bodies, and redacts OAuth-style code, state, and token query parameters before events are sent.
Example:
AUTHWALL_SENTRY_DSN=https://public@example.ingest.sentry.io/1
AUTHWALL_SENTRY_ENVIRONMENT=production
AUTHWALL_SENTRY_TRACES_SAMPLE_RATE=0.05
AUTHWALL_PERSONAL_ACCESS_TOKENS
Enables personal access tokens for API clients.
- Type: boolean flag
- Values:
yes,no,true,false,on,off - Default:
false
When disabled, Authwall does not expose the token management UI, does not register the token management routes, and does not accept bearer tokens for upstream authentication.
When enabled, signed-in users can create tokens from the profile page. The raw token is shown once, only a SHA-256 hash is stored, and API clients send it as:
Authorization: Bearer awp_...Valid bearer tokens authenticate proxied upstream requests and Authwall forwards
the same trusted X-Auth-User header it uses for browser sessions. Authwall
removes the bearer Authorization header before proxying the request.
Bearer tokens also work against GET /auth/status, so an API client can
introspect the signed-in identity without holding a browser session.
What bearer tokens cannot do
Bearer tokens are intentionally not accepted by the /auth/* account
management endpoints — creating or revoking personal access tokens, revoking
browser sessions, changing email or password, deleting the account, and so on.
Those flows require an active browser session and a CSRF token. A leaked PAT
can act on the upstream app as its owner, but it cannot escalate by reshaping
the owner's authwall account.
Usage from an API client
Once a user has minted a token from the profile page, an API client sends it
as the Authorization header on every request:
curl -H 'Authorization: Bearer awp_…' https://authwall.example.com/api/thingsThe same token works against /auth/status for identity introspection:
curl -H 'Authorization: Bearer awp_…' https://authwall.example.com/auth/statusTo rotate a token, revoke the old one from the profile page and create a new one. Authwall does not expose a regenerate endpoint by design; the explicit revoke + create steps keep the audit log clear about what happened.
Example:
AUTHWALL_PERSONAL_ACCESS_TOKENS=true
AUTHWALL_WEBSOCKETS
Enables proxying of WebSocket connections to the upstream app.
- Type: boolean flag
- Values:
yes,no,true,false,on,off - Default:
false
When disabled, Authwall does not handle the HTTP Upgrade event, and any
Upgrade: websocket request falls through as a normal HTTP request — which
the upstream will typically reject.
When enabled, Authwall accepts WebSocket upgrades on any path that isn't
under /auth/, authenticates the upgrade with a personal access token, sets
the same trusted X-Auth-User header it uses for HTTP requests, and forwards
the upgrade to the upstream.
Authenticating a WebSocket upgrade
The upgrade is authenticated with the Authorization header, so this
requires AUTHWALL_PERSONAL_ACCESS_TOKENS
to be enabled. The client sets the header on the handshake:
new WebSocket('wss://app.example.com/realtime', {
headers: {Authorization: 'Bearer awp_…'},
});Authwall validates the token, strips the Authorization header before
forwarding the upgrade, and shares the same failed-attempt rate limiter
used by HTTP bearer authentication.
The browser WebSocket API cannot set the Authorization header, so this
path is intended for non-browser clients such as a desktop app. There is no
browser/cookie-based WebSocket authentication yet.
Example:
AUTHWALL_WEBSOCKETS=trueAUTHWALL_PUBLIC_URL
Public base URL for Authwall. Authwall uses this value when building redirects and generated links that must point back to the Authwall service.
- Type: URL string
- Default:
http://127.0.0.1:3000
Set this to the externally visible URL users and OAuth providers use to reach Authwall. For production, this should usually be an HTTPS URL.
This value also drives the default of AUTHWALL_COOKIE_SECURE: when AUTHWALL_PUBLIC_URL starts
with https://, the session cookie's Secure attribute defaults to true
otherwise to false.
Example:
AUTHWALL_PUBLIC_URL=https://myapp.testAUTHWALL_PUBLIC_PATHS
Public upstream paths that bypass sign-in. These paths are proxied to AUTHWALL_UPSTREAM_URL with or without a session and never receive the X-Auth-User header.
- Type: list of path strings
- Default: the
public_pathslist inconfig/settings.yaml - Delimiters: comma, semicolon, or newline
Entries may be exact paths or prefix entries ending in /*. For example, /lib/* matches /lib/app.js and /lib/vendor/react.js.
When AUTHWALL_PUBLIC_PATHS is set, it replaces the public_paths list from config/settings.yaml.
Example:
AUTHWALL_PUBLIC_PATHS="/favicon.ico,/robots.txt,/lib/*,/designs/*"AUTHWALL_OPTIONAL_AUTH_PATHS
Public upstream paths that bypass sign-in for guests, but behave like private paths when a user is signed in. Anonymous requests are proxied without X-Auth-User; signed-in requests are proxied with X-Auth-User.
- Type: list of path strings
- Default: the
optional_auth_pathslist inconfig/settings.yaml - Delimiters: comma, semicolon, or newline
Entries may be exact paths or prefix entries ending in /*. This is useful for a landing page that should render guest content for anonymous users and signed-in content for authenticated users at the same URL.
When AUTHWALL_OPTIONAL_AUTH_PATHS is set, it replaces the optional_auth_paths list from config/settings.yaml.
Example:
AUTHWALL_OPTIONAL_AUTH_PATHS="/,/landing/*"AUTHWALL_UPSTREAM_URL
URL of the upstream application protected by Authwall.
Every request whose path is not under /auth is proxied here.
- Type: URL string
- Default:
http://127.0.0.1:8080
Use the URL that Authwall can reach from its own runtime environment.
In Docker Compose, this is usually a service URL such as http://echo-server:8080;
outside Docker it is often a loopback URL.
How requests reach the upstream:
- Authenticated requests — Authwall adds
X-Auth-User: <user_uid>to the proxied request. - Public paths are always proxied, with or without a session, and never receive the
X-Auth-Userheader. - Optional auth paths are proxied without requiring sign-in. Anonymous requests receive no
X-Auth-User; signed-in requests receiveX-Auth-Userand follow the same email-verification checks as private paths. - Other paths without a session — the user is redirected to the sign-in page; no upstream request is made.
Example:
AUTHWALL_UPSTREAM_URL=https://internal-service:8080AUTHWALL_UPSTREAM_MODE
Controls how Authwall rewrites requests when forwarding them to its single
upstream, AUTHWALL_UPSTREAM_URL. Choose the mode by how many domains sit behind
Authwall.
- Type: enum
- Values:
direct,proxy - Default:
direct
Authwall always forwards to exactly one upstream and cannot route by domain on its own. The mode decides whether that upstream is the app itself or a reverse proxy that fans out to several apps:
direct client → authwall → app
proxy client → authwall → reverse proxy → appsflowchart LR
subgraph direct
direction LR
dc[client] --> da[authwall] --> dapp[app]
end
subgraph proxy
direction LR
pc[client] --> pa[authwall] --> prp["reverse proxy"] --> papps[apps]
endUse direct when one app sits behind Authwall. Authwall rewrites the
Host header to the domain of AUTHWALL_UPSTREAM_URL, so the app receives the
request as if it had been sent straight to it.
Use proxy when several domains sit behind Authwall. The upstream is a
reverse proxy (such as nginx or Caddy) that routes each domain to its own app.
Authwall preserves the client's original Host header so that proxy can tell
the domains apart, and additionally sends X-Forwarded-For, X-Forwarded-Host,
and X-Forwarded-Proto.
Example:
AUTHWALL_UPSTREAM_MODE=proxyAUTHWALL_SET_HEADERS
Headers to add to requests before Authwall forwards them to AUTHWALL_UPSTREAM_URL.
- Type: semicolon-separated
Header-Name=valueentries - Default: none
- Validation: each header name and value must be valid for Node's HTTP client
Use this for static headers the upstream expects on every proxied request. Header values may be empty.
Outgoing headers are assembled in this order, so later steps override earlier ones:
- Authwall adds
X-Auth-Userfor authenticated, non-public-path requests. AUTHWALL_SET_HEADERSentries are applied (and may overwriteX-Auth-User).AUTHWALL_UNSET_HEADERSentries are removed.
Example:
AUTHWALL_SET_HEADERS='X-Team=notes;Authorization=Basic abc:def==;X-Empty='AUTHWALL_UNSET_HEADERS
Headers to remove from requests before Authwall forwards them to AUTHWALL_UPSTREAM_URL.
Removal happens last, after Authwall's own headers and AUTHWALL_SET_HEADERS have been
applied — see the order in AUTHWALL_SET_HEADERS.
- Type: semicolon-separated header names
- Default: none
- Validation: each header name must be valid for Node's HTTP client
Use this to drop headers the upstream should not see — typically session cookies or upstream-trusted
authorization headers leaking through from the client.
Authwall already strips every x-auth-* header from incoming requests in middleware,
so client-supplied X-Auth-User cannot reach the proxy in any case.
Example:
AUTHWALL_UNSET_HEADERS='X-Auth-User;X-Forwarded-User'AUTHWALL_DB
Database connection URI.
- Type: connection URI
- Default: SQLite database at
data/db.sqlite3 - Values: unset,
mysql://...,postgres://...,postgresql://...
Leave this unset for the default local SQLite database. Set it when Authwall should use MySQL or PostgreSQL instead.
If the URI uses any other scheme, Authwall refuses to start.
Examples:
AUTHWALL_DB=mysql://authwall:authwall@mysql/authwall
AUTHWALL_DB=postgres://authwall:authwall@postgres/authwallAUTHWALL_SEED
Bootstrap users created at startup. Authwall creates missing users and adds missing username or email identities for existing users. Entries with neither a username nor a valid email are logged and skipped.
- Type: compact string or JSON array
- Default: none
Compact format — username:password:emails, with multiple users separated by ;:
:separates the three fields per entry:username,password,emails.,separates multiple emails within the third field.;separates entries.- Either
usernameoremailsmay be empty, but not both.
AUTHWALL_SEED='admin:change-me:admin@myapp.test;ops:change-me:ops1@myapp.test,ops2@myapp.test'JSON format — an array of objects:
username— string, optional if at least one email is present.password— string used when the user is created.emails— string or array of strings; optional ifusernameis present.display_name— optional string shown in the profile.
AUTHWALL_SEED='[{"username":"admin","password":"change-me","display_name":"Admin","emails":["admin@myapp.test"]}]'
Session cookie
Configures the session cookie Authwall sets after sign-in.
AUTHWALL_COOKIE_DOMAIN—Domainattribute. Default: unset; the cookie is scoped to the exact host of the response.AUTHWALL_COOKIE_PATH—Pathattribute. Default:/. Values that do not start with/are normalized to/.AUTHWALL_COOKIE_SAMESITE—SameSiteattribute. Values:lax,strict,none. Default:lax.AUTHWALL_COOKIE_SECURE—Secureattribute. Values:yes,no,true,false. Default:truewhenAUTHWALL_PUBLIC_URLstarts withhttps://, otherwisefalse.
Modern browsers reject SameSite=None cookies that are not also Secure.
If
AUTHWALL_COOKIE_SAMESITE=noneis set withoutAUTHWALL_COOKIE_SECURE=true, Authwall refuses to start.
The cookie's Max-Age is fixed at 30 days and cannot be changed via env vars.
Example:
AUTHWALL_COOKIE_DOMAIN=myapp.test
AUTHWALL_COOKIE_PATH=/
AUTHWALL_COOKIE_SAMESITE=lax
AUTHWALL_COOKIE_SECURE=true
Access rules
Restricts which email addresses may sign in. All four variables are comma-separated lists. Comparison is case-insensitive (values are normalized to lowercase). Empty lists are ignored.
The four lists are checked in a fixed priority order, where each higher-priority list can override the next:
AUTHWALL_DENIED_EMAILS— highest priority. Listed addresses are always denied.AUTHWALL_ALLOWED_EMAILS— next. Listed addresses are always allowed, which is how you make per-address exceptions toAUTHWALL_DENIED_DOMAINS.AUTHWALL_DENIED_DOMAINS— block whole domains.AUTHWALL_ALLOWED_DOMAINS— allow whole domains.
When neither allow list is set, only the deny lists are enforced and everything else is allowed. When either allow list is set, the default flips to deny — addresses not matched by any rule are rejected.
Implementation:
async function authorize_email(email_normalized)
{
const [_, domain] = email_normalized.split('@');
const has_allowed_emails = config.access.allowed_emails.length > 0;
const has_allowed_domains = config.access.allowed_domains.length > 0;
if (config.access.denied_emails.includes(email_normalized)) {
throw new UserFriendlyError('Email is not allowed');
}
if (config.access.allowed_emails.includes(email_normalized)) {
return;
}
// denylist (always enforced)
if (config.access.denied_domains.includes(domain)) {
throw new UserFriendlyError('Email domain is not allowed');
}
if (has_allowed_domains && config.access.allowed_domains.includes(domain)) {
return;
}
// allowlist default deny
if (has_allowed_domains) {
throw new UserFriendlyError('Email domain is not allowed');
}
if (has_allowed_emails) {
throw new UserFriendlyError('Email is not allowed');
}
}Examples:
Only one address can sign in; everyone else is denied:
AUTHWALL_ALLOWED_EMAILS=admin@myapp.testA small allowlist — these three addresses can sign in, nobody else:
AUTHWALL_ALLOWED_EMAILS=alice@myapp.test,bob@myapp.test,carol@myapp.testAnyone at myapp.test can sign in, except one banned address — DENIED_EMAILS overrides ALLOWED_DOMAINS:
AUTHWALL_ALLOWED_DOMAINS=myapp.test
AUTHWALL_DENIED_EMAILS=fired@myapp.test
Email confirmation
Controls whether Authwall asks users to confirm an email address, and how the confirmation message is delivered.
AUTHWALL_CONFIRM_EMAIL_REQUIRED— whether a confirmed email is required to reach the protected app. Type: boolean flag. Values:yes,no,true,false,on,off. Default: unset, which resolves to enabled whenever the email sign-in flow is enabled. When enabled, users without a confirmed email are held at the confirmation step instead of being proxied upstream.AUTHWALL_CONFIRM_EMAIL— confirmation delivery mode. Type: enum. Values:auto,off,disabled,link,code,link_and_code. Default:auto.
How each AUTHWALL_CONFIRM_EMAIL value behaves:
auto— enabled when a mailer is configured, otherwise disabled. The default channel islink_and_code.off/disabled— email confirmation is disabled.link— confirmation emails contain only a clickable link.code— confirmation emails contain only a one-time code that the user types into the confirmation page.link_and_code— confirmation emails contain both.
Any value outside the list above disables confirmation and logs a warning.
If
AUTHWALL_CONFIRM_EMAILis one oflink,code, orlink_and_codebut no mailer is configured, Authwall refuses to start.
If
AUTHWALL_CONFIRM_EMAIL_REQUIREDis enabled while the email sign-in flow is disabled, Authwall refuses to start.
A few related knobs are not exposed as environment variables and are tuned in config/settings.yaml under confirm_email: expires_minutes (default 15), code_length (default 6), max_attempts (default 5), and resend_cooldown_seconds (default 60).
Example:
AUTHWALL_CONFIRM_EMAIL_REQUIRED=true
AUTHWALL_CONFIRM_EMAIL=codeAUTHWALL_MAILER
Selects which mailer Authwall uses to send sign-in, confirmation, password-reset, magic-link, and notification emails.
- Type: enum
- Values:
auto,fake,resend,mailjet,ses - Default:
auto
How each value behaves:
auto— picks the first provider whose required env vars are all set, in this order: Resend, Mailjet, Amazon SES. If none is configured, falls back tofake.fake— drops every email instead of sending. Suitable for local development and tests; not safe for production because users will not receive confirmation or password-reset emails.resend/mailjet/ses— uses the named provider explicitly.
When a provider is requested explicitly but not fully configured, Authwall refuses to start.
Example:
AUTHWALL_MAILER=resend
Resend
Configures the Resend mailer. Both variables are required together; the provider is usable only when both are set.
AUTHWALL_RESEND_KEY— Resend API key. Treat it as a secret: do not commit it or expose it to clients.AUTHWALL_RESEND_FROM— Sender address used in theFromheader. Typically formatted as"Display Name <noreply@myapp.test>". The domain must be verified in Resend.
With AUTHWALL_MAILER=auto (the default), Resend is selected automatically when both are set.
If
AUTHWALL_MAILER=resendis requested explicitly without both, Authwall refuses to start.
Obtain the API key from Resend → API Keys.
Example:
AUTHWALL_RESEND_KEY=re_...
AUTHWALL_RESEND_FROM="Authwall <noreply@myapp.test>"
Mailjet
Configures the Mailjet mailer. All three variables are required together; the provider is usable only when all of them are set.
AUTHWALL_MAILJET_KEY— Mailjet API key.AUTHWALL_MAILJET_SECRET— Mailjet API secret. Treat it as a secret: do not commit it or expose it to clients.AUTHWALL_MAILJET_FROM— Sender address used in theFromheader. Typically formatted as"Display Name <noreply@myapp.test>". The sender must be verified in Mailjet.
With AUTHWALL_MAILER=auto (the default), Mailjet is selected automatically when all three are set and Resend is not configured.
If
AUTHWALL_MAILER=mailjetis requested explicitly without all three, Authwall refuses to start.
Obtain credentials from Mailjet → Account Settings → API Key Management.
Example:
AUTHWALL_MAILJET_KEY=...
AUTHWALL_MAILJET_SECRET=...
AUTHWALL_MAILJET_FROM="Authwall <noreply@myapp.test>"
Amazon SES
Configures the Amazon SES mailer.
AUTHWALL_SES_KEY, AUTHWALL_SES_SECRET, and AUTHWALL_SES_FROM are required together; AUTHWALL_SES_REGION and AUTHWALL_SES_SESSION_TOKEN are optional.
AUTHWALL_SES_KEY— AWS access key ID.AUTHWALL_SES_SECRET— AWS secret access key. Treat it as a secret: do not commit it or expose it to clients.AUTHWALL_SES_FROM— Sender address used in theFromheader. Typically formatted as"Display Name <noreply@myapp.test>". The sender (or its domain) must be verified in SES.AUTHWALL_SES_REGION— AWS region for the SES endpoint. Defaults tous-east-1.AUTHWALL_SES_SESSION_TOKEN— Optional AWS session token for temporary credentials (e.g. STS / assumed roles). Omit when using long-lived IAM access keys.
With AUTHWALL_MAILER=auto (the default), SES is selected automatically when AUTHWALL_SES_KEY, AUTHWALL_SES_SECRET, and AUTHWALL_SES_FROM are all set and neither Resend nor Mailjet is configured.
If
AUTHWALL_MAILER=sesis requested explicitly withoutAUTHWALL_SES_KEY,AUTHWALL_SES_SECRET, andAUTHWALL_SES_FROM, Authwall refuses to start.
Obtain credentials from AWS IAM; verify the sender or its domain in the SES console for the chosen region.
Example:
AUTHWALL_SES_KEY=AKIA...
AUTHWALL_SES_SECRET=...
AUTHWALL_SES_REGION=us-east-1
AUTHWALL_SES_FROM="Authwall <noreply@myapp.test>"AUTHWALL_FLOWS
Selects which sign-in flows Authwall offers.
This is the last step of configuration: every other variable (mailer, OAuth credentials, password options, magic-link mode) is resolved first, and AUTHWALL_FLOWS then chooses among the flows those prior settings made available.
- Type:
autoor comma-separated list of flow names - Values:
auto, or any combination ofusername,email,magic_link,magic_code,magic_link_and_code,google,github,microsoft,facebook,twitter,discord - Default:
auto
How each value behaves:
auto— every flow whose prerequisites are already in place is enabled. Configure flows via their own env vars, and they show up automatically.- comma-separated list — only the listed flows are enabled, and each one must already be fully configured.
The magic_link_and_code value is shorthand for both magic_link and magic_code together.
When an explicit list names a flow that is missing its prerequisites, Authwall refuses to start.
Example — only password sign-in by username and Google:
AUTHWALL_FLOWS=username,googleAUTHWALL_MAGIC_LINK
Controls whether magic-link sign-in is enabled and which channel users get.
- Type: enum
- Values:
auto,off,disabled,link,code,link_and_code - Default:
auto
How each value behaves:
auto— enabled when a mailer is configured, otherwise disabled. The default channel islink_and_code.off/disabled— magic-link sign-in is disabled.link— emails contain only a clickable link.code— emails contain only a one-time code that the user types into the browser.link_and_code— emails contain both.
Any value outside the list above disables the flow and logs a warning.
If the value is one of
link,code, orlink_and_codebut no mailer is configured, Authwall refuses to start.
The magic-code retry limit is not exposed as an environment variable and is tuned in config/settings.yaml under flows.magic_link: max_attempts (default 5) — the number of code guesses allowed per issued code before it is rejected.
Example:
AUTHWALL_MAGIC_LINK=code
Google OAuth
Configures sign-in with Google. All three variables are required together; the flow is enabled only when all of them are set.
AUTHWALL_GOOGLE_CLIENT_ID— OAuth 2.0 client identifier issued by Google.AUTHWALL_GOOGLE_CLIENT_SECRET— OAuth 2.0 client secret. Treat it as a secret: do not commit it or expose it to clients.AUTHWALL_GOOGLE_REDIRECT_URL— Callback URL Google redirects to after the user authorizes Authwall. It must match an Authorized Redirect URI registered on the OAuth client in Google Cloud Console; otherwise Google rejects the request. Authwall handles the callback at/auth/google/callback, so this is normally<AUTHWALL_PUBLIC_URL>/auth/google/callback.
If only some of the three are set, Authwall logs a warning and disables the Google flow.
If
AUTHWALL_FLOWS=googleis requested explicitly without all three, Authwall refuses to start.
Obtain values from Google Cloud Console → APIs & Services → Credentials.
Example:
AUTHWALL_GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com
AUTHWALL_GOOGLE_CLIENT_SECRET=GOCSPX-...
AUTHWALL_GOOGLE_REDIRECT_URL=https://myapp.test/auth/google/callback
GitHub OAuth
Configures sign-in with GitHub. All three variables are required together; the flow is enabled only when all of them are set.
AUTHWALL_GITHUB_CLIENT_ID— OAuth client identifier issued by GitHub.AUTHWALL_GITHUB_CLIENT_SECRET— OAuth client secret. Treat it as a secret: do not commit it or expose it to clients.AUTHWALL_GITHUB_REDIRECT_URL— Callback URL GitHub redirects to after the user authorizes Authwall. It must match the Authorization callback URL registered on the OAuth App in GitHub; otherwise GitHub rejects the request. Authwall handles the callback at/auth/github/callback, so this is normally<AUTHWALL_PUBLIC_URL>/auth/github/callback.
If only some of the three are set, Authwall logs a warning and disables the GitHub flow.
If
AUTHWALL_FLOWS=githubis requested explicitly without all three, Authwall refuses to start.
Obtain values from GitHub → Settings → Developer settings → OAuth Apps.
Example:
AUTHWALL_GITHUB_CLIENT_ID=Iv1.abcdef1234567890
AUTHWALL_GITHUB_CLIENT_SECRET=ghs_...
AUTHWALL_GITHUB_REDIRECT_URL=https://myapp.test/auth/github/callback
Facebook OAuth
Configures sign-in with Facebook. All three variables are required together; the flow is enabled only when all of them are set.
AUTHWALL_FACEBOOK_CLIENT_ID— App ID issued by Meta.AUTHWALL_FACEBOOK_CLIENT_SECRET— App Secret. Treat it as a secret: do not commit it or expose it to clients.AUTHWALL_FACEBOOK_REDIRECT_URL— Callback URL Facebook redirects to after the user authorizes Authwall. It must match a Valid OAuth Redirect URI configured on the app in Meta for Developers; otherwise Facebook rejects the request. Authwall handles the callback at/auth/facebook/callback, so this is normally<AUTHWALL_PUBLIC_URL>/auth/facebook/callback.
If only some of the three are set, Authwall logs a warning and disables the Facebook flow.
If
AUTHWALL_FLOWS=facebookis requested explicitly without all three, Authwall refuses to start.
Obtain values from Meta for Developers → My Apps → your app → Facebook Login → Settings.
Example:
AUTHWALL_FACEBOOK_CLIENT_ID=1234567890123456
AUTHWALL_FACEBOOK_CLIENT_SECRET=...
AUTHWALL_FACEBOOK_REDIRECT_URL=https://myapp.test/auth/facebook/callback
Microsoft OAuth
Configures sign-in with Microsoft. All three variables are required together; the flow is enabled only when all of them are set.
AUTHWALL_MICROSOFT_CLIENT_ID— Application (client) ID from the app registration.AUTHWALL_MICROSOFT_CLIENT_SECRET— Client secret value. Treat it as a secret: do not commit it or expose it to clients.AUTHWALL_MICROSOFT_REDIRECT_URL— Callback URL Microsoft redirects to after the user authorizes Authwall. It must match a Redirect URI registered on the app registration in Microsoft Entra; otherwise Microsoft rejects the request. Authwall handles the callback at/auth/microsoft/callback, so this is normally<AUTHWALL_PUBLIC_URL>/auth/microsoft/callback.
If only some of the three are set, Authwall logs a warning and disables the Microsoft flow.
If
AUTHWALL_FLOWS=microsoftis requested explicitly without all three, Authwall refuses to start.
Obtain values from Microsoft Entra admin center → Identity → Applications → App registrations.
Example:
AUTHWALL_MICROSOFT_CLIENT_ID=00000000-0000-0000-0000-000000000000
AUTHWALL_MICROSOFT_CLIENT_SECRET=...
AUTHWALL_MICROSOFT_REDIRECT_URL=https://myapp.test/auth/microsoft/callback
X OAuth
Configures sign-in with X (formerly Twitter). All three variables are required together; the flow is enabled only when all of them are set.
The variables keep their TWITTER names for compatibility, even though the product is now called X.
AUTHWALL_TWITTER_CLIENT_ID— OAuth 2.0 Client ID from the X app.AUTHWALL_TWITTER_CLIENT_SECRET— OAuth 2.0 Client Secret. Treat it as a secret: do not commit it or expose it to clients.AUTHWALL_TWITTER_REDIRECT_URL— Callback URL X redirects to after the user authorizes Authwall. It must match a Callback URI registered on the OAuth 2.0 client in the X Developer Portal; otherwise X rejects the request. Authwall handles the callback at/auth/twitter/callback, so this is normally<AUTHWALL_PUBLIC_URL>/auth/twitter/callback.
If only some of the three are set, Authwall logs a warning and disables the X flow.
If
AUTHWALL_FLOWS=twitteris requested explicitly without all three, Authwall refuses to start.
Obtain values from X Developer Portal → Projects & Apps → your app → User authentication settings.
Example:
AUTHWALL_TWITTER_CLIENT_ID=...
AUTHWALL_TWITTER_CLIENT_SECRET=...
AUTHWALL_TWITTER_REDIRECT_URL=https://myapp.test/auth/twitter/callback
Discord OAuth
Configures sign-in with Discord. All three variables are required together; the flow is enabled only when all of them are set.
AUTHWALL_DISCORD_CLIENT_ID— Application's Client ID.AUTHWALL_DISCORD_CLIENT_SECRET— Application's Client Secret. Treat it as a secret: do not commit it or expose it to clients.AUTHWALL_DISCORD_REDIRECT_URL— Callback URL Discord redirects to after the user authorizes Authwall. It must match a Redirect registered on the application in the Discord Developer Portal; otherwise Discord rejects the request. Authwall handles the callback at/auth/discord/callback, so this is normally<AUTHWALL_PUBLIC_URL>/auth/discord/callback.
If only some of the three are set, Authwall logs a warning and disables the Discord flow.
If
AUTHWALL_FLOWS=discordis requested explicitly without all three, Authwall refuses to start.
Obtain values from Discord Developer Portal → Applications → your application → OAuth2.
Example:
AUTHWALL_DISCORD_CLIENT_ID=1234567890123456789
AUTHWALL_DISCORD_CLIENT_SECRET=...
AUTHWALL_DISCORD_REDIRECT_URL=https://myapp.test/auth/discord/callback
Glossary
| Term | Description |
|---|---|
| Access rules | Email-based rules that decide whether a user may sign in. Deny lists are checked before allow lists. |
| Access token | A token returned by an OAuth provider after Authwall exchanges an authorization code. Authwall uses it to fetch provider user information during the OAuth callback. |
| Account | The Authwall user record that owns sessions, profile data, password hash, and linked identities. |
| Account removal | The flow that deletes a user's account, sessions, identities, and pending tokens while preserving auth events with user_id cleared. |
| Allow list | A configured set of email addresses or domains that are allowed to sign in. When an allow list exists, unlisted email identities are denied. |
| Auth event | An audit record for authentication activity such as sign-in, sign-out, password change, identity linking, session revocation, or account removal. |
| Authorization code | A short-lived OAuth value returned to Authwall's callback URL after the provider approves the sign-in attempt. Authwall exchanges it for provider tokens. |
| Authorization code flow | The OAuth redirect flow used by Authwall providers: redirect to provider, receive a code at the callback URL, exchange the code, fetch user info, then create or update the Authwall session. |
| Authwall | The authentication proxy that sits in front of an upstream application, handles sign-in, and forwards authenticated requests. |
| CSRF token | A per-session token required for state-changing Authwall form/API actions so another site cannot submit those actions through the user's browser. |
| Callback URL | The Authwall route where an OAuth provider sends the browser after approval, for example /auth/google/callback. Also called the redirect URL or redirect URI. |
| Client ID | The public OAuth application identifier issued by a provider and configured in Authwall with variables such as AUTHWALL_GOOGLE_CLIENT_ID. |
| Client secret | The private OAuth application secret issued by a provider and configured in Authwall with variables such as AUTHWALL_GOOGLE_CLIENT_SECRET. |
| Confidential client | A server-side OAuth client that can keep a client secret private. Authwall behaves as a confidential client because token exchange happens on the server. |
| Deny list | A configured set of email addresses or domains that are blocked from sign-in even if another rule would otherwise allow them. |
| Direct upstream mode | Upstream mode where Authwall changes the upstream request origin for direct application requests. |
| Email change | The flow that lets a signed-in user request and confirm a new email identity when a mailer is configured. |
| Email confirmation | The flow that confirms the user controls an email address before marking that email identity as verified. |
| Email identity | A user identity of type email. Verified email identities can be used for email/password sign-in and access-rule checks. |
| Grant type | An OAuth exchange pattern. Authwall currently uses the authorization code grant for provider sign-in. |
| Identity | A sign-in handle linked to an account, such as a username, verified email, Google account, GitHub account, Microsoft account, Facebook account, X account, or Discord account. |
| Magic code | A one-time code emailed to a user for passwordless sign-in. |
| Magic link | A one-time emailed link that signs in the user without a password. |
| Mailer | The configured email delivery provider used for magic links, password reset, email confirmation, email change, and security notifications. |
| Normalized identity value | The canonical value used for uniqueness and lookup, such as lowercased email or provider account id. |
| OAuth connect | The authenticated flow that links a new OAuth provider identity to the current Authwall account. |
| OAuth disconnect | The authenticated flow that removes an OAuth provider identity from the current account when it is not the last remaining sign-in method. |
| OAuth login | The flow that signs a user in through an external provider and creates an Authwall account when no linked provider identity exists yet. |
| OAuth provider | An external identity provider supported by Authwall, such as Google, GitHub, Microsoft, Facebook, X, or Discord. |
| OAuth state | A random value stored in the session and sent through the OAuth redirect to protect callbacks from cross-site or stale responses. Authwall also uses it to distinguish login from connect intent. |
| Open registration | The default mode where a new account can be created by anyone who completes an enabled sign-in or sign-up flow and passes access rules. |
| PKCE | OAuth proof key for code exchange. Authwall stores a verifier in the session, sends a challenge to the provider, and uses the verifier during token exchange. |
| Password flow | Username/password or email/password sign-in and sign-up, controlled by Authwall flow settings and password policy. |
| Password reset | The email-based flow that lets a user set a new password using a time-limited reset token. |
| Profile | The authenticated Authwall page and API surface for account details, identities, email changes, password changes, and account removal. |
| Protected request | Any non-Authwall, non-public-path request that requires a signed-in session before Authwall proxies it to the upstream application. |
| Proxy upstream mode | Upstream mode where Authwall forwards proxy headers such as X-Forwarded-* to the upstream application. |
| Public URL | The externally visible base URL for Authwall. It is used for redirects, generated links, and secure-cookie defaults. |
| Public client | An OAuth client that cannot keep a client secret private, such as browser-only or mobile code. Authwall's server-side OAuth flows are not public-client flows. |
| Public path | A configured upstream path that Authwall may proxy without requiring a signed-in session. |
| Redirect URI | OAuth name for the registered callback URL. In Authwall configuration this is called the provider redirect URL. |
| Redirect URL | The exact OAuth callback URL registered with the provider and configured in Authwall, such as AUTHWALL_GOOGLE_REDIRECT_URL. |
| Refresh token | A provider token that can request new access tokens without another browser approval step. Authwall does not depend on refresh tokens for normal sign-in. |
| Root secret | The secret configured with AUTHWALL_SECRET or generated in data/secret.key; Authwall derives the session secret from it via HKDF. (CSRF tokens are random per-session values, not derived from it.) |
| Seed user | A bootstrap user created at startup from AUTHWALL_SEED. Useful for initial deployments or test environments. |
| Session | The server-side record and browser cookie that keep a user signed in. Sessions include user id, user uid, IP, user agent, expiration, and CSRF data. |
| Sign-in flow | One enabled way to authenticate, such as username/password, email/password, magic link, magic code, or an OAuth provider. |
| Upstream application | The protected application Authwall forwards allowed requests to. Its URL is configured with AUTHWALL_UPSTREAM_URL; AUTHWALL_UPSTREAM_MODE controls how requests are proxied to it. |
| Upstream headers | Headers that Authwall adds to or removes from upstream requests using AUTHWALL_SET_HEADERS and AUTHWALL_UNSET_HEADERS. |
| User UID | The stable public user identifier forwarded to the upstream application in X-Auth-User. |
| Verified email | An email address that Authwall or an OAuth provider has confirmed. OAuth access rules require a verified provider email when allow or deny rules are configured. |
| X-Auth-User | The request header Authwall sets on authenticated upstream requests. Its value is the signed-in user's uid. Incoming X-Auth-* headers are removed before proxying. |