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 run recipes, 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_FLOWS selects 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-User trust 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 app
sequenceDiagram
    participant client
    participant authwall
    participant your_app

    client ->> authwall: request
    authwall ->> your_app: request (X-Auth-User)
    your_app -->> authwall: response
    authwall -->> client: response

Quick 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/authwall

Behavior:

  • sign-in: username + password
  • registration: open
  • email features: disabled
  • storage: SQLite (ephemeral unless volume mounted)

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/authwall

Behavior:

  • 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/callback

Then 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/authwall

Behavior:

  • 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/authwall

Behavior:

  • 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 startdocker run works 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:

  1. Use AUTHWALL_SECRET when it is set.
  2. Otherwise, load /app/data/secret.key if it already exists.
  3. 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_SECRET still 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.

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 app
flowchart 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/authwall

Open http://localhost:3000, choose Sign up, and create the first account.

What this gives you:

  • Storage: SQLite — Authwall's default whenever AUTHWALL_DB is 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/data to 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 version should work).
  • Port 3000 free 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/authwall
  • AUTHWALL_PUBLIC_URL — the URL users reach Authwall on. Used to build links and redirects.
  • AUTHWALL_UPSTREAM_URL — the upstream app. Here it points at the echo-server service 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 .env

Later, 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/callback

3. Start the stack

docker compose up -d

On 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 authwall

You 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, secret

Next steps

  • Configuration reference — every environment variable.
  • Point AUTHWALL_UPSTREAM_URL at your own app instead of echo-server.
  • Add a mailer to unlock magic-link sign-in and email confirmation (see the AUTHWALL_MAILER section of the configuration reference).
  • Add OAuth providers (Google, GitHub, Microsoft, Facebook, X, Discord) via their *_CLIENT_ID / *_CLIENT_SECRET / *_REDIRECT_URL variables.
  • 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/authwall

Open 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_URL points 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 /auth is proxied to the upstream; authenticated requests carry X-Auth-User
  • ./data/authwall holds the SQLite database and secret.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 admin user exists on first boot; sign in with admin / change-me and 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 auto mode, configuring an OAuth provider turns the password flows off
  • the access rules admit only verified @myapp.test Google 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/authwall

An 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/authwall

A 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 trusted X-Auth-User header it sets on HTTP requests
  • the browser WebSocket API cannot set the Authorization header, 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 up

Each 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:8080 over the Compose network; the app is never published, so it is reachable only through Authwall
  • ./data/authwall holds the SQLite database and secret.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/authwall still holds secret.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-stopped
  • AUTHWALL_SEED creates the admin user on first boot; sign in with admin / change-me and change the password from the profile
  • personal access tokens let signed-in users mint bearer tokens, and AUTHWALL_WEBSOCKETS proxies upgrades that authenticate with one (tokens are required for the WebSocket path)
  • it runs over plain HTTP here; in production set AUTHWALL_PUBLIC_URL to your https:// origin and the session cookie becomes Secure automatically (see Deployment)

To stop the stack and wipe users, sessions, and the secret:

docker compose down && rm -rf data

Going 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 node user;
  • uses dumb-init as PID 1 for correct signal handling;
  • sets NODE_ENV=production, LISTEN=0.0.0.0, PORT=3000, and AUTHWALL_LOGGER=stdout by 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 app
flowchart 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.test

When 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:

  1. AUTHWALL_SECRET if it is set (must be at least 32 characters);
  2. otherwise /app/data/secret.key if that file exists;
  3. 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_DB is 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_DB to a mysql:// or postgres:// 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_DOMAIN to the shared parent domain.
  • Cross-siteAUTHWALL_COOKIE_SAMESITE=none requires AUTHWALL_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 OK

Restricting 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_URL to the HTTPS URL.
  • [ ] Persist the data directory, or set AUTHWALL_SECRET explicitly.
  • [ ] Use MySQL or PostgreSQL if you need backups or multiple instances.
  • [ ] Confirm AUTHWALL_COOKIE_SECURE is true (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/health into your liveness/readiness probes.
  • [ ] Set AUTHWALL_SENTRY_DSN if 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 up

All 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
                      authwall
flowchart 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 ~~~ sidecar

Examples

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 → app
flowchart LR
    client --> authwall --> app

This 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 up

Then 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 of app:8080.
  • AUTHWALL_PUBLIC_URL — set it to the URL users actually reach Authwall on.
  • AUTHWALL_UPSTREAM_MODE — leave it direct while one app sits behind Authwall. Switch to proxy only when the upstream is a reverse proxy serving several domains (see the authwall-proxy-nginx example).

Notes

  • Storage is SQLite, kept in ./data along 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_URL to the https:// 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 --> echo

Authwall 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.test

Run it

docker compose up

Open 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

  • authwall is the only published service (port 3000); all three domains resolve to it.
  • AUTHWALL_UPSTREAM_MODE=proxy tells Authwall to keep the client's original Host header when forwarding to its single upstream, http://nginx.
  • nginx.conf is the router: a map turns the request Host into the matching upstream, and a single server block proxies to it — or returns 404 for an unknown domain. It is the whole nginx config, mounted over /etc/nginx/nginx.conf.
  • AUTHWALL_COOKIE_DOMAIN=mydomain.test shares the session across the subdomains, so users sign in once.

What to change for your app

  • Replace the apps / notes / echo services with your own apps.
  • Edit the map $host $upstream entries in nginx.conf to 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_URL to the https:// 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-stopped

nginx.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 --> echo

Authwall 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.test

Run it

docker compose up

Open 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

  • authwall is the only published service (port 3000); all three domains resolve to it.
  • AUTHWALL_UPSTREAM_MODE=proxy tells Authwall to keep the client's original Host header when forwarding to its single upstream, http://caddy.
  • The Caddyfile listens on plain HTTP and uses a host matcher per domain, routing each to its app (apps, notes, echo).
  • AUTHWALL_COOKIE_DOMAIN=mydomain.test shares the session across the subdomains, so users sign in once.

What to change for your app

  • Replace the apps / notes / echo services with your own apps.
  • Edit the host matchers and reverse_proxy targets in the Caddyfile to 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_URL to the https:// 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-stopped

Caddyfile

# 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
         authwall
flowchart LR
    client --> nginx
    nginx --> apps
    nginx --> notes
    nginx --> echo
    nginx -.auth check.-> authwall

Use 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.test

Run it

docker compose up

Then 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/sidecar endpoint.
  • everything else is the protected app. For each request nginx makes an internal auth_request to /auth/sidecar; on 200 it copies X-Auth-User and proxies to the app picked from the request Host by a map; on 401 it redirects the browser to the sign-in page with a return URL 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-User is 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 / echo services directly, or requests would bypass the auth check.

What to change for your app

  • Replace the apps / notes / echo services with your own apps.
  • Edit the map $host $upstream entries in nginx.conf to 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_URL accordingly.

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-stopped

nginx.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
         authwall
flowchart LR
    client --> caddy
    caddy --> apps
    caddy --> notes
    caddy --> echo
    caddy -.auth check.-> authwall

Use 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.test

Run it

docker compose up

Then 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/sidecar endpoint.
  • everything else is the protected app. forward_auth makes a subrequest to Authwall's /auth/sidecar; on 200 Caddy copies X-Auth-User and proxies to the app; on 401 it redirects the browser to the sign-in page with a relative return path 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-User is 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 / echo services directly, or requests would bypass the auth check.

What to change for your app

  • Replace the apps / notes / echo services with your own apps.
  • Add or edit the per-domain site blocks in the Caddyfile to 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; set AUTHWALL_PUBLIC_URL to 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-stopped

Caddyfile

# 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  discord

There 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,google

Explicit 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,email

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 in auto).
  • 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=code

OAuth

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,microsoft

Restricting 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 signed state value. 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 rulesAUTHWALL_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_FLOWS names 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>/callback

For 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.


Google

  • 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/callback

GitHub

  • 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/callback

Microsoft

  • 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

Facebook

  • 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/callback

X (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/callback

Discord

  • Console: Discord Developer Portal → Applications → your application → OAuth2.
  • Redirect: add <AUTHWALL_PUBLIC_URL>/auth/discord/callback under 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/callback

Limiting 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.com

Because 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_name is empty, the renderer tidies Hi , down to Hi,.

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.

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 that COPYs 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/migrate for 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-08

It 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 every x-auth-* header it received from the client. A client cannot smuggle X-Auth-User: admin through Authwall.
  • Authwall sets X-Auth-User itself, 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 forwards X-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 with 403 Email verification required.
  • When AUTHWALL_WEBSOCKETS is enabled, the same guarantee extends to upgrades: clients authenticate the upgrade with an Authorization: Bearer personal access token. Authwall strips inbound x-auth-* headers, removes the credential before forwarding, and sets X-Auth-User itself.

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) and SameSite (lax by default — see AUTHWALL_COOKIE_SAMESITE).
  • It is marked Secure automatically when AUTHWALL_PUBLIC_URL is https:// — 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_events audit 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_URL is https://.
  • [ ] The upstream proxy or load balancer overwrites X-Forwarded-For from the real client (see Running behind a proxy). Otherwise rate-limit keys, last-used IPs, and audit IPs are spoofable.
  • [ ] AUTHWALL_SECRET is managed deliberately, or data/ 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_ROUNDS is 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.1 when running from source; the published Docker image bakes in LISTEN=0.0.0.0 so 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=8000

AUTHWALL_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: daily when running from source; the published Docker image bakes in AUTHWALL_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=13

AUTHWALL_RATE_LIMITING

Toggles Authwall's built-in per-IP rate limiting.

  • Type: string flag
  • Values: 0 to 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 429 with Retry-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 as production or staging.
  • 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/things

The same token works against /auth/status for identity introspection:

curl -H 'Authorization: Bearer awp_…' https://authwall.example.com/auth/status

To 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=true

AUTHWALL_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.test

AUTHWALL_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_paths list in config/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_paths list in config/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-User header.
  • Optional auth paths are proxied without requiring sign-in. Anonymous requests receive no X-Auth-User; signed-in requests receive X-Auth-User and 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:8080

AUTHWALL_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 → apps
flowchart 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]
    end

Use 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=proxy

AUTHWALL_SET_HEADERS

Headers to add to requests before Authwall forwards them to AUTHWALL_UPSTREAM_URL.

  • Type: semicolon-separated Header-Name=value entries
  • 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:

  1. Authwall adds X-Auth-User for authenticated, non-public-path requests.
  2. AUTHWALL_SET_HEADERS entries are applied (and may overwrite X-Auth-User).
  3. AUTHWALL_UNSET_HEADERS entries 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/authwall

AUTHWALL_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 username or emails may 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 if username is present.
  • display_name — optional string shown in the profile.
AUTHWALL_SEED='[{"username":"admin","password":"change-me","display_name":"Admin","emails":["admin@myapp.test"]}]'

Configures the session cookie Authwall sets after sign-in.

  • AUTHWALL_COOKIE_DOMAINDomain attribute. Default: unset; the cookie is scoped to the exact host of the response.
  • AUTHWALL_COOKIE_PATHPath attribute. Default: /. Values that do not start with / are normalized to /.
  • AUTHWALL_COOKIE_SAMESITESameSite attribute. Values: lax, strict, none. Default: lax.
  • AUTHWALL_COOKIE_SECURESecure attribute. Values: yes, no, true, false. Default: true when AUTHWALL_PUBLIC_URL starts with https://, otherwise false.

Modern browsers reject SameSite=None cookies that are not also Secure.

If AUTHWALL_COOKIE_SAMESITE=none is set without AUTHWALL_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:

  1. AUTHWALL_DENIED_EMAILS — highest priority. Listed addresses are always denied.
  2. AUTHWALL_ALLOWED_EMAILS — next. Listed addresses are always allowed, which is how you make per-address exceptions to AUTHWALL_DENIED_DOMAINS.
  3. AUTHWALL_DENIED_DOMAINS — block whole domains.
  4. 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.test

A small allowlist — these three addresses can sign in, nobody else:

AUTHWALL_ALLOWED_EMAILS=alice@myapp.test,bob@myapp.test,carol@myapp.test

Anyone 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 is link_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_EMAIL is one of link, code, or link_and_code but no mailer is configured, Authwall refuses to start.

If AUTHWALL_CONFIRM_EMAIL_REQUIRED is 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=code

AUTHWALL_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 to fake.
  • 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 the From header. 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=resend is 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 the From header. 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=mailjet is 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 the From header. 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 to us-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=ses is requested explicitly without AUTHWALL_SES_KEY, AUTHWALL_SES_SECRET, and AUTHWALL_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: auto or comma-separated list of flow names
  • Values: auto, or any combination of username, 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,google

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 is link_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, or link_and_code but 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=google is 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=github is 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=facebook is 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=microsoft is 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=twitter is 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=discord is 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.