# Security model

> How goodnight-web protects the admin screen, the contact form, and your secrets, and what it does not protect against.

This page explains what goodnight-web protects and how, and what you need to do when you run a site. The limits are listed at the end, in [What this does not protect against](#limits).

## What you need to do [#checklist]

- Use a random value of at least 32 characters for `APP_SECRET` ([Secrets](#secrets)).
- Keep `APP_SECRET` after setup, and keep a copy in a password manager. The site uses it for sign-in and IP hashes. If you set a separate `SETUP_TOKEN`, delete it after setup.
- Turn on two-factor authentication for your Cloudflare account ([Why the database is not reachable from the network](#database-access)).
- Put Cloudflare Access on the admin paths. It is optional but strongly recommended ([Cloudflare Access](#cloudflare-access)).
- Once your own domain works, set `"workers_dev": false` in `wrangler.jsonc` and commit it. Preview URLs are already off (`"preview_urls": false`).
- Turn on **Always Use HTTPS** for your zone ([HTTPS](#https)).
- Keep your repository private, and merge only pull requests that have the official update comment and a passing update check ([Updates and your repository](#updates)).

## Principles

- **Fail closed.** When a required setting or secret is missing, the feature that needs it is turned off. For example, without `TURNSTILE_SECRET` the contact API returns 503.
- **Never open the admin screen without sign-in.** There is no development bypass, default account, or shared key.
- **Do not lose inquiries.** An inquiry is saved before the response. Suspected spam (a filled honeypot or a high spam score) is saved and listed separately, not dropped.
- **Keep personal data inside Cloudflare.** Notifications carry no personal data. Raw IP addresses are not stored.

## Admin screen

| Topic                          | How it is handled                                                                                                                                                                                                                                                                                                                                  |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Sign-in                        | Passkeys through Better Auth, nothing else. Email and password sign-in is off, because password hashing can exceed the Workers free plan's CPU limit ([better-auth#8860](https://github.com/better-auth/better-auth/issues/8860)). See [Passkey sign-in](https://goodnight-web.dev/docs/how-it-works/passkeys.md).                                                            |
| Sessions                       | The admin API accepts only a session created at or after the user's first passkey. A session from setup is never an admin session. See [Sessions and setup](#sessions-and-setup).                                                                                                                                                                  |
| `ADMIN_EMAILS`                 | Read again on every request. Removing an address locks its sessions out of the admin API at the next request, which also deletes them. See [Removing an admin](#removing-an-admin).                                                                                                                                                                |
| HTTPS                          | The admin screen, the admin API, setup, sign-in, and the contact API work only over `https://`. See [HTTPS](#https).                                                                                                                                                                                                                               |
| Cloudflare Access              | Optional, but strongly recommended. Free for up to 50 users. See [Cloudflare Access](#cloudflare-access).                                                                                                                                                                                                                                          |
| First admin                    | Requires the setup token (`APP_SECRET`, or `SETUP_TOKEN` if it is set) and an address in `ADMIN_EMAILS`. Setup closes permanently once the first passkey is registered. See [First admin setup](https://goodnight-web.dev/docs/how-it-works/first-admin.md).                                                                                                                  |
| Cloudflare WAF                 | Optional. Filters requests to your domain with rules you set. See [Cloudflare WAF](#cloudflare-waf).                                                                                                                                                                                                                                               |
| `workers.dev` and preview URLs | These addresses are easy to leave out of an Access policy. Preview URLs are off in the starter's `wrangler.jsonc`. `workers.dev` stays on until you set `"workers_dev": false` there after adding your own domain; a switch in the dashboard is undone by the next deploy. Even without Access, the admin screen still requires a passkey sign-in. |
| Request size                   | Sign-in requests (`/api/auth/*`) must be sent as `application/json` and be at most 64 KiB. Setup accepts at most 4 KiB and an inquiry update in the admin API at most 4 KiB. A larger body gets 413 before it is parsed, and Better Auth never reads it.                                                                                           |
| CSRF                           | `Origin` checks and `SameSite` cookies.                                                                                                                                                                                                                                                                                                            |
| Headers                        | A Content Security Policy and other security headers on Worker responses. The Worker picks the headers after it normalizes the path, so `/%61dmin`, `//admin`, and `/admin/..%2f` get the same headers as `/admin`.                                                                                                                                |

The admin screen itself can load without a session, but every request for data needs one.

### Public pages share the admin origin [#shared-origin]

The site's pages, the admin screen, and the admin API are served from the same origin (for example `https://example.com`). The browser therefore treats a script on any public page like a script of the admin screen: while an admin is signed in, such a script can call the admin API with the admin's session and read, export, delete, or change inquiries and the form. The `HttpOnly` cookie and the `Origin` check do not stop a script that runs on the same origin.

The starter keeps this risk small:

- The pages are built from your repository. Only Cloudflare Turnstile is loaded from another origin.
- `public/_headers` sends a Content Security Policy on every page. It allows scripts only from the site itself and from `https://challenges.cloudflare.com`, allows no inline scripts, and forbids framing (`frame-ancestors 'none'`). Astro writes every script to a file under `/_astro/` for this. The admin screen and the API get their own, stricter headers from the Worker.
- The starter's `AGENTS.md` tells AI tools not to add third-party scripts, embeds, or HTML with scripts unless you ask for them.

If you add an analytics tag, a chat widget, a tag manager, or an embed that loads a script, that script runs with this authority. Add it only if you trust it, and add its origin to `public/_headers` yourself. The only full isolation is a separate hostname for the admin screen, such as `admin.example.com`, that serves `/admin`, `/api/admin/*`, `/api/setup`, and `/api/auth/*` and none of the public pages.

### Sessions and setup [#sessions-and-setup]

Only a session from a passkey sign-in or from registering the first passkey can be an admin session. These rules make sure of that:

- When setup runs again for an address that already has a user, that user's earlier sessions are deleted first.
- Registering the first passkey closes setup and deletes every session in the database. The browser gets a new session from the passkey registration itself.
- Once setup is closed, a user without a passkey cannot register one. A session left over from setup cannot create another admin.
- The admin API returns 401 for a session created before the user's first passkey, even if that session was not deleted.

### HTTPS [#https]

Over plain HTTP, the setup token, session cookies, passkey requests, and the values a visitor types into the contact form would cross the network in clear text. So `/admin`, `/admin/*`, and every path under `/api/` require `https://`: the admin API, setup, sign-in, and the contact API.

- `GET` and `HEAD` over `http://` are redirected to the same URL on `https://` (308).
- Other methods over `http://` get 403 with the code `https_required`. They are not redirected, because the body was already sent.
- `localhost`, `127.0.0.1`, and `[::1]` are exempt, so that `wrangler dev` works.

So the contact form on a page opened over `http://` cannot load or send. This rule does not cover the static pages. To serve every page over HTTPS, turn on **Always Use HTTPS** for your zone (Cloudflare dashboard > SSL/TLS > Edge Certificates).

### Cloudflare Access [#cloudflare-access]

Cloudflare Access adds a second sign-in in front of the passkey, for example a Google login or a one-time code by email. It is free for up to 50 users.

Put the Access application on these paths:

- `/admin` and `/admin/*`
- `/api/admin/*`
- `/api/setup`
- `/api/auth/*`

Keep `/api/contact` public. Visitors submit the contact form there.

To have the Worker check the Access token as well, set both of these:

| Setting              | Value                                                                                                        |
| -------------------- | ------------------------------------------------------------------------------------------------------------ |
| `ACCESS_AUD`         | The Access application's **Application Audience (AUD) Tag**. Several tags can be given, separated by commas. |
| `ACCESS_TEAM_DOMAIN` | Your team domain, such as `your-team.cloudflareaccess.com`.                                                  |

With both set, the Worker checks the `Cf-Access-Jwt-Assertion` header on the paths above, so setup of the first admin also needs a valid token. It checks the RS256 signature, the issuer, the audience, `exp`, and `nbf` ([Cloudflare: validate JWTs](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/)). The signing keys come from `https://<team domain>/cdn-cgi/access/certs`, and the Worker caches them for an hour. If fetching them fails, the Worker does not try again for 10 seconds, and requests in between get 503.

- A missing or invalid token gets 403 (`access_denied`).
- Those paths return 503 (`access_unavailable`) when only one of the two settings is set, when `ACCESS_TEAM_DOMAIN` is not a plain hostname, or when the keys cannot be fetched.
- Without either setting, the Worker does not check Access tokens. Access in front of the site still works; the Worker just does not double-check it.
- Passkey sign-in is still required with Access.

### Cloudflare WAF [#cloudflare-waf]

[Cloudflare WAF](https://goodnight-web.dev/docs/glossary.md#waf) checks requests to your domain against rules before they reach the Worker. It is optional. goodnight-web does not depend on it: Turnstile, the rate limit, and the input checks run in the Worker either way.

WAF rules apply to a zone, a domain added to your Cloudflare account. Custom rules, which can match a request by properties such as the URL path or the IP address and then block or challenge it, are available on every plan, with a limit on the number of rules that depends on the plan. See Cloudflare's documentation:

- [Cloudflare WAF](https://developers.cloudflare.com/waf/)
- [Custom rules](https://developers.cloudflare.com/waf/custom-rules/)
- [Managed rules](https://developers.cloudflare.com/waf/managed-rules/)

Test a rule before you rely on it. A rule that blocks or challenges `/api/contact` can stop real visitors from sending the contact form. To restrict who can reach the admin screen, [Cloudflare Access](#cloudflare-access) is the better fit.

### Removing an admin [#removing-an-admin]

To remove an admin, delete their address from `ADMIN_EMAILS`. The Worker reads `ADMIN_EMAILS` on every request, so that admin's next request to the admin API gets 403. The same request deletes every session of that user. Until that request, the sessions stay in D1: if you add the address back first, they work again. To end them now, run the command below. A new passkey sign-in does not help: the next admin API request refuses that session and deletes it too.

The check covers the admin API (`/api/admin/*`). Better Auth's own endpoints under `/api/auth/*`, such as adding a passkey, do not read `ADMIN_EMAILS`, but nothing they do gives access to inquiries or the form.

Admin API requests do not extend a session's expiry; only Better Auth's own session endpoint does. So a removed admin cannot keep a session alive through the admin API.

The user record and its passkeys stay in D1, so adding the address again later lets that person sign in with the same passkeys. Sessions that were already deleted do not come back. To delete the user and the passkeys permanently, run this yourself (replace the address):

```sh
npx wrangler d1 execute DB --remote --command "DELETE FROM session WHERE user_id IN (SELECT id FROM user WHERE email = 'old@example.com'); DELETE FROM passkey WHERE user_id IN (SELECT id FROM user WHERE email = 'old@example.com'); DELETE FROM account WHERE user_id IN (SELECT id FROM user WHERE email = 'old@example.com'); DELETE FROM user WHERE email = 'old@example.com';"
```

The command does not touch `form_definitions.created_by`, which keeps the address of the admin who saved each form version. That table is append-only (database triggers refuse updates and deletes), so the address stays for as long as the database does.

This does not open setup again: `settings.setup_closed_at` stays. To open setup on purpose, for example after losing every passkey, follow [When you cannot use or have lost your passkey](https://goodnight-web.dev/docs/how-it-works/passkey-recovery.md).

## Contact form

The contact API checks a submission like this before it saves it. [Contact form protection](https://goodnight-web.dev/docs/how-it-works/contact-protection.md) explains how each check works.

- `Origin`, `Content-Type`, and the body size are checked before the body is parsed.
- Turnstile is verified on the server, including `hostname` and `action`.
- The Rate Limiting binding limits submissions per hashed IP address (per /64 prefix for IPv6).
- At most 500 submissions are stored per hour across all senders. Above that, the API returns 503 (`contact_busy`) and stores nothing. Requests that arrive at the same moment can go slightly over the limit.
- A filled honeypot field does not change the response. The submission is stored as suspected spam only if it passes Turnstile, the rate limit, and validation. The honeypot value itself is not stored.
- A high spam score also flags a submission as suspected spam. Suspected spam gets no Slack notification and is listed separately in the admin screen.
- There is no API key in the page. A key that the browser can read is not a secret, so it would only give a false sense of protection.

## Why the database is not reachable from the network [#database-access]

D1 is not a database you connect to over the network. Postgres and MySQL are reached over TCP with a driver, but Cloudflare documents D1 as [accessed through a Workers binding rather than a network connection](https://developers.cloudflare.com/workers/databases/connecting-to-databases/). There are three ways to work with a D1 database ([Cloudflare: Query a database](https://developers.cloudflare.com/d1/best-practices/query-d1/)):

- **A Worker with a binding.** A [binding](https://goodnight-web.dev/docs/glossary.md#binding) gives one Worker access to the database. The Worker holds no database password or key ([Cloudflare: Bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/)).
- **The REST API.** A request needs an API token, or the older API key, and the token needs the `D1 Read` or `D1 Write` permission ([Cloudflare: Query D1 Database](https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/query/)).
- **Wrangler and the Cloudflare dashboard.** Wrangler commands use the REST API. The dashboard is where you work after you sign in to your account.

The stored data is encrypted at rest. Cloudflare's documentation says this covers every object stored in D1, happens automatically, and needs no configuration ([Cloudflare: D1 data security](https://developers.cloudflare.com/d1/reference/data-security/)).

In goodnight-web, only the Worker code uses the `DB` binding. The contact API, the admin API, and the Better Auth sign-in routes read and write the database through it. The built pages are static files, and a visitor can send only a contact form, whose values the Worker checks before it saves them. So a visitor has no way to query the database directly, and what needs protecting is:

- **Your Cloudflare account.** Anyone who can sign in to it, or who holds an API token with D1 permission, can read the database. Turn on [two-factor authentication](https://developers.cloudflare.com/fundamentals/user-profiles/2fa/).
- **The admin sign-in.** It uses passkeys, and you can add [Cloudflare Access](#cloudflare-access) in front of it.
- **The Worker code.** Every value reaches D1 as a bound parameter through Drizzle, and input is checked before it is used. See [SQL injection](#sql-injection).

## SQL injection [#sql-injection]

The server never puts input into SQL text. Every value goes to D1 as a bound parameter.

- Every query uses the [Drizzle ORM](https://orm.drizzle.team/) query builder. The page size and offset of the admin list are parameters too.
- Numbers from the URL are checked at the route before they reach a query. `page` must be 1 to 100,000 and `limit` 1 to 100, written with digits only. Values such as `1; DROP TABLE submissions`, `-1`, `1e9`, or `50 OFFSET 0` get a 400 response.
- Raw SQL is allowed only through Drizzle's `sql` template, which binds its values. A CI check (`bun run check:sql`) fails when the server code calls `sql.raw()` or D1's `prepare()` directly, or reaches the D1 binding through Drizzle's `$client` (see [Database](https://goodnight-web.dev/docs/database.md#how-queries-are-written)).
- The email search escapes `%`, `_`, and `\`. It sends the pattern as one bound parameter, with `ESCAPE '\'`.
- Tests send injection strings to the admin list and check for 400. They also record the statements sent to the database and check that input appears only in the parameters.

The route passes URL numbers through this schema, which turns a digits-only string into a bounded integer:

```ts title="packages/server/src/features/admin-api/admin-query.ts"
const boundedInteger = (max: number) =>
  z
    .string()
    .max(String(max).length)
    .regex(/^[1-9][0-9]*$/)
    .transform(Number)
    .pipe(z.number().int().min(1).max(max));
```

```ts title="packages/server/src/features/admin-api/admin-query.ts"
export const listQuerySchema = z
  .strictObject({
    ...filterShape,
    dir: z.enum(['asc', 'desc']).default('desc'),
    page: boundedInteger(ADMIN_MAX_PAGE).default(1),
    limit: boundedInteger(ADMIN_MAX_PAGE_SIZE).default(ADMIN_PAGE_SIZE),
  })
  .refine(bothOrNeither);
```

`filterShape` holds `view` (`unread`, `read`, `all`, or `spam`), `q` (the email search, at most 254 characters), and the version and option filters, each with its own zod schema. The store then builds the query with Drizzle (see [Database](https://goodnight-web.dev/docs/database.md#how-queries-are-written)).

For example, for `?view=unread&q=50%_off&page=3`, the test suite's recording D1 receives this statement and these parameters:

```sql
select "id", "created_at", "email", "status", "suspected_spam", ... from "submissions"
where (("submissions"."status" = ? and "submissions"."suspected_spam" = ?)
  and lower("submissions"."email") like ? escape '\')
order by "submissions"."created_at" desc, "submissions"."id" desc
limit ? offset ?
```

```json
["unread", 0, "%50\\%\\_off%", 50, 100]
```

The SQL text is the same for every search term, page, and page size. Only the parameters change. See [Database](https://goodnight-web.dev/docs/database.md).

## Secrets [#secrets]

- All secrets (`APP_SECRET`, `ADMIN_EMAILS`, `TURNSTILE_SECRET`, `SLACK_WEBHOOK_URL`, and the optional `SETUP_TOKEN`, `BETTER_AUTH_SECRET`, and `IP_HASH_SECRET`) are Worker secrets.
- Secrets are never sent to the browser and never written to D1. The admin screen does not show the Slack webhook URL. It only shows whether the URL is set and in the expected format.
- The key that signs admin sessions and the key that hashes IP addresses are derived from `APP_SECRET` with [HKDF](https://goodnight-web.dev/docs/glossary.md#hkdf) in Web Crypto. Each purpose has its own fixed label, so one key does not reveal the other key or `APP_SECRET`. Derived keys exist only in the Worker's memory and are never stored.
- `APP_SECRET` is also the setup token unless `SETUP_TOKEN` is set. This gives nothing extra to someone who has it: the session key comes from it, so they could already sign a session. If you prefer separate keys, set `SETUP_TOKEN`, `BETTER_AUTH_SECRET`, or `IP_HASH_SECRET`; a value you set wins over the derived one ([Separate keys](https://goodnight-web.dev/docs/getting-started.md#separate-keys)).
- Do not delete or change `APP_SECRET` after setup. Deleting it turns setup, sign-in, the admin API, and the contact form off (503). Changing it signs every admin out and changes IP hashes. Passkeys and inquiries are not affected.
- Every secret (`APP_SECRET` and the optional `SETUP_TOKEN`, `BETTER_AUTH_SECRET`, and `IP_HASH_SECRET`) must be at least 32 characters. A shorter value counts as missing, so setup, sign-in, or the contact form returns 503. A value with fewer than 10 distinct characters, or one short block repeated, is refused the same way. This basic check catches placeholders and hand-typed values; it does not measure randomness, so generate each value with `openssl rand -base64 32`. Nothing in goodnight-web or the Deploy button form generates them for you. A set value that is too short or not random enough is not replaced by a key derived from `APP_SECRET`.
- While setup is open, `GET /api/setup` lists the names of the settings that are missing, too short, or not random enough (never the values). The admin screen also says when the key for IP hashes is missing, too short, or not random enough, by the name of the secret to fix.
- Better Auth reads a few variables from the Worker's environment by itself. goodnight-web turns its origin and CSRF checks on explicitly, so a variable named `TEST` or `NODE_ENV` cannot turn them off. Sign-in stays off (503) while `BETTER_AUTH_TRUSTED_ORIGINS` or `BETTER_AUTH_SECRETS` is set, because they would add trusted origins or replace `BETTER_AUTH_SECRET`.
- There is no update button in the admin screen, because it would need a token that can write to your GitHub repository.

## CSV export

- The export reads 200 rows per query and streams them, so a large table never sits in the Worker's memory as one file.
- To keep spreadsheet apps from running a cell as a formula, a cell whose first visible character is `=`, `+`, `-`, or `@`, or that starts with a tab or carriage return, gets a leading `'`. The app then shows it as text. Whitespace and invisible characters such as zero-width spaces and bidi controls before it are skipped, and the full-width forms `＝`, `＋`, `－`, and `＠` count too.

## Updates and your repository [#updates]

- Fixes arrive as Dependabot pull requests. Merge only pull requests that have the official update comment and a passing `goodnight-web/update-check` check. See [Distribution and updates](https://goodnight-web.dev/docs/distribution.md).
- Keep your repository private. The starter's visibility check fails while it is public.
- goodnight-web has few dependencies of its own and pins them to exact versions.

## What this does not protect against [#limits]

- Someone with access to your Cloudflare account or your GitHub repository.
- Someone else using a device that holds one of your passkeys.
- Merging a pull request that is not an official update.
- Content that you publish on your own site, and the code of any change you make to the starter.
- Spam that passes Turnstile and the rate limit. The honeypot and the spam score flag much of it, but some will reach the normal list.

## Reporting a vulnerability

Report vulnerabilities privately through GitHub Security Advisories: open the goodnight-web repository's **Security** tab and choose **Report a vulnerability**. Do not open a public issue.
