Security
Creator Notify is a small service with a large blast radius if misused: whoever holds a site's API key can send notifications to that site's subscribers. This page states what the code does, what it does not do, and what stays your responsibility.
No absolute security
This document describes a threat model and a set of controls. It is not a guarantee. Review the source, test your deployment, and treat the secret key with the same care as a database password.
What is worth protecting
| Asset | Impact if exposed | Storage |
|---|---|---|
| Site secret API key | Anyone can push notifications to that site's subscribers | HMAC-SHA-256 with SECRET_PEPPER; plaintext exists only in the response that created it |
Subscription auth secret | Can decrypt messages addressed to that subscription | AES-256-GCM envelope, key in SUBSCRIPTION_ENCRYPTION_KEY |
| Subscription endpoint URL | Identifies a device to the push service; enables targeted pushes | Stored, never returned by any API response, never logged |
| VAPID private key | Impersonate your deployment to push services | Worker secret only |
SECRET_PEPPER | Turns a database dump into an offline key-cracking exercise | Worker secret only |
Threat model
| Threat | Mitigation in this codebase |
|---|---|
| Cross-tenant access (site A reads or notifies site B) | Tenant identity is derived only from the hashed API key. Every query filters by site_id. No endpoint accepts a caller-supplied site id for writes. |
| Stolen API key | Rotation (rotate-key), revocation (revoke-key), key prefixes/hints for identification, per-site rate limits to bound abuse. |
| Database dump | Keys are peppered hashes; subscription auth secrets are encrypted; endpoints are stored but never exposed through the API. |
| Injection | All SQL uses bound parameters. JSON bodies reject __proto__, prototype and constructor keys, and are depth/size limited. |
| XSS via notification payload | Server rejects control characters and non-HTTP(S) URLs; the Service Worker validates every URL against an origin allow-list before navigation. |
Open redirect via url | Navigation is limited to the site's own origin (plus origins explicitly configured in the worker). |
| Notification spam from a third-party page | Subscription creation requires the registered origin; per-IP and per-site rate limits; endpoint host allow-list; payload size limits. |
| Resource exhaustion | Bounded concurrency, bounded batch sizes, per-site subscription caps, request body ceilings, retention windows. |
| Credential leakage in logs | Error logging emits an error name/message and request id only; endpoint URLs, keys and SQL are never logged. Stack traces are suppressed when ENVIRONMENT=production. |
| Cross-site request from a browser | Management endpoints send no CORS headers at all; browser-facing endpoints only reflect registered origins. Auth is still required — CORS is a browser rule, not an auth check. |
Credential handling
API keys
stored: HMAC-SHA-256(key = SECRET_PEPPER, message = api_key)
returned: once, in the 201 response of /v1/sites/register or /v1/sites/rotate-key
lookup: hash the presented key, then SELECT ... WHERE secret_key_hash = ?
- Keys are 40 base62 characters behind a
cn_live_prefix (≈238 bits of entropy) — unguessable and easy to spot in secret scanners. - Comparison happens inside SQL against an indexed hash, so there is no secret-dependent branching in application code.
api_key_prefixandapi_key_hintare non-secret identifiers for operator support; they cannot be used to authenticate.
Subscription auth secrets
envelope: v1.<base64url(iv)>.<base64url(ciphertext+tag)>
alg: AES-256-GCM, 96-bit random IV per record, 128-bit tag
AAD: the subscription endpoint URL
Binding the ciphertext to the endpoint prevents an attacker with database write access from copying an encrypted secret between rows: decryption fails when the endpoint does not match.
What is not encrypted
p256dh (the subscriber's public key) and the endpoint URL are stored in plaintext because they are
needed to build each push request. The endpoint is treated as sensitive: it is never returned by the API and
never written to logs.
Origins and CORS
- The
Originheader is used to decide which registration a browser may address, and it must exactly equal the registered origin. - An
OriginorRefererheader is never treated as proof of authorization for a write. - Management endpoints emit no
Access-Control-Allow-Origin; a browser cannot use a key it does not have, and a compromised page cannot read responses. CORS_ALLOWED_ORIGINS(comma separated) extends the public allow-list — for example a documentation demo origin. It does not grant any authorization.
Rate limiting and abuse prevention
Fixed windows stored in the service database, one row per scope/subject/window, incremented with a single atomic upsert. Because the counter lives in the database, limits apply across Worker isolates and colo regions rather than per process.
- Anonymous endpoints are keyed by client IP; authenticated endpoints by site id.
- Subscription endpoints are keyed by site + IP, so one abuser cannot exhaust a whole tenant's budget or lock out other users of the same site.
- Counters are purged by the cron job after
RATE_LIMIT_RETENTION_HOURS. - Set
REGISTRATION_TOKENto stop strangers registering origins on a public deployment. - An operator can layer a CDN or WAF rate limit in front of these — recommended for public deployments.
Your deployment responsibilities
- Secret storage. Use your platform's encrypted secret store (or your CI secret store). Never commit
.dev.varsor.env. - Key custody on your servers. Environment variables or a secrets manager, readable only by the app user. Not in web-root files, not in JS bundles.
- Transport. Serve your site and the API over HTTPS only. HSTS is recommended.
- Access to the operator account. Restrict console and API-token access; the operator can read tenant data for every site on the instance.
- Registration policy. Set
REGISTRATION_TOKENunless you intend public registration. - Logs. Worker logs contain request ids and error names. Configure retention to match your policy.
- Retention. Tune the retention variables to your jurisdiction's requirements.
- Incident response. Rotate the affected site key, then the platform secrets if you suspect the deployment itself was compromised.
If a key leaks
POST /v1/sites/rotate-keywith the leaked key (it still works until you rotate) — the old key dies instantly.- If the attacker already rotated the key, you no longer control the registration: use
revoke-keyfrom a session that still authenticates, or ask the operator to suspend the registration. - Review
GET /v1/notificationsfor messages you did not send. - If
SECRET_PEPPERitself leaked, rotate it and re-issue every site key (existing hashes become unverifiable by design).
Reporting a vulnerability
See SECURITY.md in the repository for the disclosure process. Please do not open a public issue for
a live vulnerability.