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.
- Choosing a mode
- Settings
- Passkeys · Passwords · Passkeys, Google and Microsoft
- The administrator account
- Sessions and sensitive actions
- When someone is locked out
- Changing the mode later
- Testing by hand
- What is not here
Choosing a mode
Section titled “Choosing a mode”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, fullThere 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.
Settings
Section titled “Settings”| 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:
passkeyorfullwith aBASE_URLthat is neitherhttps://norlocalhost— browsers refuse passkeys on any other origin;- an
AUTH_RP_IDthat is not the host ofBASE_URLor a parent domain of it (example.comis fine forwatch.example.com;other.comis not), or that is a bare top-level domain; - an
AUTH_TRUSTED_ORIGINSentry outside that domain, nothttps://, or not an origin at all (a path is not an origin); fullwithout 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.
Passkeys (AUTH_MODE=passkey)
Section titled “Passkeys (AUTH_MODE=passkey)”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.
Passwords (AUTH_MODE=password)
Section titled “Passwords (AUTH_MODE=password)”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:
docker compose exec silencewatch node packages/server/dist/auth/cli.js \ reset-password --email jane@example.comIt 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.
Registering the applications
Section titled “Registering the applications”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 inGOOGLE_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) intoMICROSOFT_CLIENT_SECRET; the Application (client) ID isMICROSOFT_CLIENT_ID. Client secrets expire: put the date in your calendar.
One address, several ways in
Section titled “One address, several ways in”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, same address | The same account | |
| 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.
The administrator account
Section titled “The administrator account”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.
Sessions and sensitive actions
Section titled “Sessions and sensitive actions”- 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_tokenover HTTPS):HttpOnly,SameSite=Lax,Secureover HTTPS. The page cannot read it. - A write made with a session cookie must come from
BASE_URLor one ofAUTH_TRUSTED_ORIGINS: theOriginheader 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
403withdetails.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.
When someone is locked out
Section titled “When someone is locked out”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.
Support commands
Section titled “Support commands”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 the mode later
Section titled “Changing the mode later”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.
Testing by hand
Section titled “Testing by hand”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:
AUTH_TRUSTED_ORIGINS=http://localhost:4200The 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.
What is not here
Section titled “What is not here”- 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.