Skip to content

Authentication

{/* Generated from docs/authentication.md by scripts/prepare.mjs. Edit that file. */}

How people sign in to SilenceWatch, what each way costs you to run, and what to do when someone is locked out.

Sign-in is Better Auth, configured by one setting, AUTH_MODE. The mode decides which methods exist: the ones it does not name have no endpoint at all, not a disabled one. API keys and heartbeat URLs are a separate system and are not affected by anything on this page.

AUTH_MODE People sign in with Pick it when
passkey (default) A passkey: fingerprint, face or device PIN. Recovery codes if every passkey is lost Anyone can reach the instance and you want nothing to phish or leak
password An email address and a password (12 characters at least) A private instance where passkeys are more than the team wants to learn
full Google, Microsoft, or a passkey added after the first sign-in The people already have Google or Microsoft accounts, and you would rather not hold a credential for them

Any other value stops the server at startup and lists the valid ones:

Invalid SilenceWatch configuration:
- AUTH_MODE: "both" is not a sign-in mode. Valid values: passkey, password, full

There is no mix beyond what the table says: no passkey on top of a password, no Google beside a password. Fewer methods is less to attack, and less to explain to the person who is locked out.

Variable Default Meaning
AUTH_MODE passkey passkey, password or full
BASE_URL http://localhost:8080 What your users type. Must be https:// in passkey and full unless it is localhost
SECRET_KEY — Signs session cookies and seals the channels’ webhook URLs and secrets. Rotating it signs everyone out, and needs silencewatch reseal
ADMIN_EMAIL — Required. The administrator’s address. See the administrator account
SIGNUP_ENABLED true Whether anyone may create an account. When false, the administrator’s is the only one that can be
AUTH_RP_ID host of BASE_URL The domain passkeys are bound to. Decide once: see below
AUTH_RP_NAME SilenceWatch The name the browser shows when asking for a passkey
AUTH_TRUSTED_ORIGINS — Extra browser origins that may sign in (a development server, say), comma-separated
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET — Google sign-in (AUTH_MODE=full). The button shows only when both are set
MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET — Microsoft sign-in, same rule. full needs at least one provider
MICROSOFT_TENANT_ID common common accepts work, school and personal accounts; a tenant id restricts sign-in to that organisation
AUTH_RATE_LIMIT_PER_MINUTE 10 Sign-in attempts per minute from one address, per endpoint

The server refuses to start when this cannot work, rather than starting and turning every sign-in away:

  • passkey or full with a BASE_URL that is neither https:// nor localhost — browsers refuse passkeys on any other origin;
  • an AUTH_RP_ID that is not the host of BASE_URL or a parent domain of it (example.com is fine for watch.example.com; other.com is not), or that is a bare top-level domain;
  • an AUTH_TRUSTED_ORIGINS entry outside that domain, not https://, or not an origin at all (a path is not an origin);
  • full without all four provider credentials.

Decide AUTH_RP_ID once. A passkey belongs to a domain: change it and every passkey made before stops working, with no way to move them. If you might move the instance to another subdomain later, set AUTH_RP_ID to the parent domain now.

Creating an account takes an email address and a passkey. There is no password to choose, no email to confirm: the address is a label, nothing is sent to it. The account is created only once the browser has produced a passkey that verifies. Straight afterwards the page shows eight recovery codes, once.

Signing in is one button; the browser offers the passkeys it has for this site.

Recovery codes are for the day every device holding a passkey is gone. Each works once. Only a keyed hash is stored, so reading the database yields nothing that signs anyone in. Signing in with one asks you to make a new passkey on the device in your hand. Settings → Sign-in says how many are left and makes a new set (which replaces the old one, and needs a sign-in from the last ten minutes).

Several passkeys per account is the normal case: add one per device in Settings → Sign-in, so that losing one never matters. The last one cannot be removed from the page.

A passkey needs the page to be on the domain it was made for, in a secure context. Behind a reverse proxy, BASE_URL has to be the address people type — not the address of the container.

At least 12 characters, no composition rules (length is what counts). Passwords are hashed with scrypt, Better Auth’s default, with a salt per password; the hash lives in account.password and nothing else holds it.

Guessing is slowed three ways: the per-endpoint rate limit (AUTH_RATE_LIMIT_PER_MINUTE), a lockout after 10 failures in a row that lasts 15 minutes and ends the moment the right password is given after it, and the same answer (“Invalid email or password”) whether the address exists, the password is wrong or the account is locked.

There is no “forgot my password” by email — SilenceWatch sends no mail to prove an address. Whoever runs the instance resets it:

Terminal window
docker compose exec silencewatch node packages/server/dist/auth/cli.js \
reset-password --email jane@example.com

It prints a new random password once and ends the account’s sessions. The person changes it in Settings → Sign-in, which signs their other devices out.

Passkeys, Google and Microsoft (AUTH_MODE=full)

Section titled “Passkeys, Google and Microsoft (AUTH_MODE=full)”

People create their account with Google or Microsoft and sign in with either. Once signed in, a passkey is offered once (and always available in Settings → Sign-in); from then on it signs them in without the provider.

A passkey is only ever added to an account that exists. Creating an account from a typed address is not offered here, because nothing would prove the address — and a Google sign-in arriving later with the same address must not land in an account somebody else made first.

Recovery is the provider: someone who lost their passkey signs in with Google or Microsoft again, and adds a new one.

Redirect URIs are BASE_URL followed by /api/auth/callback/google and /api/auth/callback/microsoft.

  • Google — Google Cloud console → APIs & Services → Credentials → OAuth client ID, type Web application. Authorised redirect URI: https://watch.example.com/api/auth/callback/google. Put the client id and secret in GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET. Set the consent screen to External (or Internal for a Workspace-only instance) and publish it, or only test users can sign in.
  • Microsoft — Entra admin center → App registrations → New registration. Supported account types: any organisational directory and personal Microsoft accounts for MICROSOFT_TENANT_ID=common, or the single-tenant option together with your tenant id. Redirect URI (platform Web): https://watch.example.com/api/auth/callback/microsoft. Under Certificates & secrets, create a client secret and copy its Value (not its id) into MICROSOFT_CLIENT_SECRET; the Application (client) ID is MICROSOFT_CLIENT_ID. Client secrets expire: put the date in your calendar.

An account is found by its address. What happens when the same address arrives by a second route:

The account was made with A sign-in arrives with Result
Google Google, same address The same account
Google Microsoft, same address Refused. Microsoft cannot vouch for the address (see below)
Microsoft Google, same address Refused. The account’s address was never proven
Anything Google, address Google has not verified Refused

A refused sign-in lands on the sign-in page with a plain message and creates nothing. The person signs in the way they first did.

Why Microsoft is not trusted: the address in a Microsoft token is whatever the administrator of that tenant set, so trusting it would let anyone who runs a tenant sign in as anyone. Better Auth identifies a Microsoft account by its immutable oid, which is what keeps it from being confused with another.

ADMIN_EMAIL is required: the server does not start without it. The account with that address is the administrator’s. It is the only one that sees the instance’s figures (Settings › Statistics: accounts, checks, subscriptions, alert emails) and its internals (GET /api/v1/status); to every other account those answer 404, and to an API key 401.

Nothing proves that an address typed into a sign-up form is its owner’s, and this account sees what no other does. So it is created with a one-time link, and in no other way, whether sign-up is open or not:

No administrator account yet. Create it for jane@example.com with this link, once, before 2026-10-17T10:30:00.000Z:
https://watch.example.com/setup?email=jane%40example.com#Tkj3…
  • It is printed in the server log (docker compose logs silencewatch) at every start while that account does not exist. It expires after 30 minutes and works once, for that address only. The token is in the part of the URL after #, which a browser never sends to a server.

  • Missed it? Restart the server, or print one now:

    Terminal window
    docker compose exec silencewatch silencewatch create-admin

Everyone else: anyone may create an account while SIGNUP_ENABLED=true, through the abuse limits in abuse-prevention.md. With SIGNUP_ENABLED=false, the administrator’s account is the only one that can be created. To let more people in to a closed instance, open sign-up for the time it takes, then close it again.

Opening the link takes the person to the sign-in page with the address fixed; they create their account in whichever way the mode allows.

  • A session lasts 30 days and slides: using it after a day moves its end forward, so a person who comes every week stays signed in and one who stops is signed out a month later.
  • It is a cookie named sw.session_token (__Secure-sw.session_token over HTTPS): HttpOnly, SameSite=Lax, Secure over HTTPS. The page cannot read it.
  • A write made with a session cookie must come from BASE_URL or one of AUTH_TRUSTED_ORIGINS: the Origin header is checked, and anything else is answered 403. API keys are not cookies and are not subject to it.
  • An account keeps at most 20 live sessions; the oldest are ended.
  • Deleting the account, creating or revoking an API key, adding or removing a passkey and making new recovery codes need a sign-in from the last 10 minutes. The page asks the person to sign in again, with the method the instance has, and carries on. Over the API the refusal is 403 with details.reauthRequired: true.

The audit trail (Settings → Security activity) records sign-ins and failures (with the address that tried, including addresses that have no account), sign-outs, password changes, passkeys added and removed, recovery codes made and used, and account creation and deletion.

Everything below is for whoever runs the server; none of it is an endpoint.

Mode Situation What to do
passkey Lost every passkey, still has recovery codes They use a code on the sign-in page
passkey Lost every passkey and the codes cli.js recovery-codes --email … prints a new set, once
password Forgot the password cli.js reset-password --email …
full Lost their passkey They sign in with Google or Microsoft
full Lost access to the Google or Microsoft account Not recoverable here: that is the provider’s to restore
any The administrator account does not exist yet cli.js create-admin prints its setup link (the server also prints one at every start)

Run them as silencewatch <command> --email <address> in the server container, for instance docker compose exec silencewatch silencewatch show --email jane@example.com. (The long form, node packages/server/dist/auth/cli.js, still works, and is the only one in an image older than the short name.) The password and the codes are printed to the terminal and nowhere else.

The same command line answers the everyday requests of running a service. Run silencewatch with no arguments to print this list.

Command What it does
show Prints the account: plan, subscription, sign-in methods, live sessions, lockout and projects. Changes nothing, and prints no secret.
create-admin A one-time setup link that creates the administrator’s account (ADMIN_EMAIL), the only way to create it. Takes no --email.
recovery-codes A new set of recovery codes, once; the old ones stop working. AUTH_MODE=passkey only.
reset-password A new random password, once; ends the account’s sessions. AUTH_MODE=password only.
unlock Ends a lockout after too many wrong passwords.
end-sessions Signs the account out everywhere.
disable Shuts an account out, for one that looks like abuse. No sign-in by any method (password, passkey, recovery code, Google or Microsoft), its sessions are ended, and the API keys of the projects it alone owns are refused. Nothing is deleted. Its checks keep running and alerting, and a Stripe subscription keeps billing: deal with those in the project and in Stripe.
enable Undoes disable.
verify-email Marks the address as verified. Google can only be linked to an existing account whose address is verified, and an account created with a passkey has none to prove it.
reseal Takes no --email. Seals every notification channel’s configuration under the current SECRET_KEY. After changing SECRET_KEY, run it with OLD_SECRET_KEY=<the previous value>, or the channels cannot be read. Safe to run again: it reports what it sealed, re-sealed and left alone.
set-plan --plan <name> Moves the account to a plan from PLAN_LIMITS; --plan none removes the limits. An account with a Stripe subscription gets its plan set again by the next event.

Every command that changes something writes one operator.change line to the audit trail, naming the command and the account (never a password or a code).

Changing AUTH_MODE never deletes an account, a project or a check. It changes how people prove who they are, so some of them have to be let back in once. Stop and start the server with the new value; nothing needs migrating.

From → to What people find What you do
password → passkey Their password no longer works; no passkey yet Per account, recovery-codes --email … and hand over the codes: they sign in with one and add a passkey
password → full Provider sign-in is refused: their address was never proven If you know the address is theirs, UPDATE "user" SET email_verified = true WHERE email = '…'; — then Google (only) links to it. Or let them make a new account
passkey → password No password Per account, reset-password --email … and hand over the password
passkey → full Provider sign-in is refused: the address was never proven As above; their passkeys keep working in full
full → passkey No passkey, unless they added one Per account, recovery-codes --email …; those who added a passkey sign in as before
full → password No password Per account, reset-password --email …

Passkeys and passwords stay in the database when their mode is off, and come back when it is on again.

Passkeys without a device. In Chrome or Edge, open DevTools → ⋮ → More tools → WebAuthn → Enable virtual authenticator environment → Add authenticator (protocol ctap2, transport internal, tick resident keys and user verification). Create an account and sign in on http://localhost:8080; the virtual authenticator answers for you.

Providers locally. In full mode with BASE_URL=http://localhost:8080, register the redirect URIs with http://localhost:8080 as the host: both Google and Microsoft accept localhost over plain HTTP for development.

The Angular dev server (pnpm dev:web, on http://localhost:4200) is another origin, so it has to be named, and AUTH_RP_ID stays localhost:

Terminal window
AUTH_TRUSTED_ORIGINS=http://localhost:4200

The repository’s end-to-end tests run a software authenticator through the real WebAuthn verification, and stand-ins for the two providers; see development.md.

  • No email. Nothing is sent to prove an address, to reset a password or to invite someone. That is deliberate: it removes a service the instance would have to keep up for sign-in to work. Alert emails are a separate thing.
  • No second factor on top of a password, and no passkey beside a password: pick the method, not a stack of them.
  • No sign-in API for machines. Scripts and CI use API keys.
  • No selection of other providers. Adding one is a code change, not a setting.