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.
What you need to do
- Use a random value of at least 32 characters for
APP_SECRET(Secrets). - Keep
APP_SECRETafter setup, and keep a copy in a password manager. The site uses it for sign-in and IP hashes. If you set a separateSETUP_TOKEN, delete it after setup. - Turn on two-factor authentication for your Cloudflare account (Why the database is not reachable from the network).
- Put Cloudflare Access on the admin paths. It is optional but strongly recommended (Cloudflare Access).
- Once your own domain works, set
"workers_dev": falseinwrangler.jsoncand commit it. Preview URLs are already off ("preview_urls": false). - Turn on Always Use HTTPS for your zone (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).
Principles
- Fail closed. When a required setting or secret is missing, the feature that needs it is turned off. For example, without
TURNSTILE_SECRETthe 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). See Passkey sign-in. |
| 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. |
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. |
| HTTPS | The admin screen, the admin API, setup, sign-in, and the contact API work only over https://. See HTTPS. |
| Cloudflare Access | Optional, but strongly recommended. Free for up to 50 users. See 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. |
| Cloudflare WAF | Optional. Filters requests to your domain with rules you set. See 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
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/_headerssends a Content Security Policy on every page. It allows scripts only from the site itself and fromhttps://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.mdtells 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
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
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.
GETandHEADoverhttp://are redirected to the same URL onhttps://(308).- Other methods over
http://get 403 with the codehttps_required. They are not redirected, because the body was already sent. localhost,127.0.0.1, and[::1]are exempt, so thatwrangler devworks.
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 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:
/adminand/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). 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, whenACCESS_TEAM_DOMAINis 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 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:
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 is the better fit.
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):
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.
Contact form
The contact API checks a submission like this before it saves it. Contact form protection 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
hostnameandaction. - 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
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. There are three ways to work with a D1 database (Cloudflare: Query a database):
- A Worker with a binding. A binding gives one Worker access to the database. The Worker holds no database password or key (Cloudflare: Bindings).
- The REST API. A request needs an API token, or the older API key, and the token needs the
D1 ReadorD1 Writepermission (Cloudflare: Query D1 Database). - 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).
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.
- The admin sign-in. It uses passkeys, and you can add 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
The server never puts input into SQL text. Every value goes to D1 as a bound parameter.
- Every query uses the Drizzle ORM 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.
pagemust be 1 to 100,000 andlimit1 to 100, written with digits only. Values such as1; DROP TABLE submissions,-1,1e9, or50 OFFSET 0get a 400 response. - Raw SQL is allowed only through Drizzle's
sqltemplate, which binds its values. A CI check (bun run check:sql) fails when the server code callssql.raw()or D1'sprepare()directly, or reaches the D1 binding through Drizzle's$client(see Database). - The email search escapes
%,_, and\. It sends the pattern as one bound parameter, withESCAPE '\'. - 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:
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));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).
For example, for ?view=unread&q=50%_off&page=3, the test suite's recording D1 receives this statement and these parameters:
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 ?["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.
Secrets
- All secrets (
APP_SECRET,ADMIN_EMAILS,TURNSTILE_SECRET,SLACK_WEBHOOK_URL, and the optionalSETUP_TOKEN,BETTER_AUTH_SECRET, andIP_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_SECRETwith HKDF in Web Crypto. Each purpose has its own fixed label, so one key does not reveal the other key orAPP_SECRET. Derived keys exist only in the Worker's memory and are never stored. APP_SECRETis also the setup token unlessSETUP_TOKENis 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, setSETUP_TOKEN,BETTER_AUTH_SECRET, orIP_HASH_SECRET; a value you set wins over the derived one (Separate keys).- Do not delete or change
APP_SECRETafter 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_SECRETand the optionalSETUP_TOKEN,BETTER_AUTH_SECRET, andIP_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 withopenssl 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 fromAPP_SECRET. - While setup is open,
GET /api/setuplists 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
TESTorNODE_ENVcannot turn them off. Sign-in stays off (503) whileBETTER_AUTH_TRUSTED_ORIGINSorBETTER_AUTH_SECRETSis set, because they would add trusted origins or replaceBETTER_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
- Fixes arrive as Dependabot pull requests. Merge only pull requests that have the official update comment and a passing
goodnight-web/update-checkcheck. See Distribution and updates. - 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
- 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.