Private links are useful for distribution. They are not authorization. Any state-changing endpoint behind a private-link page still needs its own access check, write throttling, and request audit trail.
This note describes a redacted form-endpoint incident using placeholder routes and synthetic examples only. Operational identifiers are intentionally omitted.
Impact
The incident affected data integrity, not confidentiality. A public write endpoint accepted hundreds of synthetic form-shaped submissions over a short period. The result was a polluted response table that needed backup, filtering, cleanup, and verification.
No evidence indicated credential exposure or arbitrary database access. The write path used parameterized SQL and bounded input handling, so the abuse stayed inside the intended insert/update operation. The practical failure was operational: the application allowed untrusted callers to create trusted-looking rows too quickly.
What Failed
The page and the API had different protection models. The page route was
gated by a shared query parameter and marked with
noindex headers.
The backing API route accepted JSON directly and did not require the same
token at the write boundary.
| 1 | GET /private-page?invite=<shared-token> # gated page |
| 2 | POST /api/forms/<form>/submit # ungated write path |
The original server-side validation answered only "is this payload shaped like a valid form submission?" It did not answer "is this caller allowed to write to this form?" That left the endpoint scriptable by any client that discovered the route.
Why It Happened
Private-link systems often conflate three separate controls:
- Discovery control: make the page hard to find without the link.
- Read control: decide whether the page should render.
- Write control: decide whether an action should mutate state.
The incident came from implementing the first two controls while omitting the third. Once the write endpoint was visible in client-side code, the page gate no longer mattered. The API needed to treat the invite token as part of the submission authorization, not merely as part of page access.
| 1 | function canRenderPage(url, env) { |
| 2 | return url.searchParams.get("invite") === env.INVITE_TOKEN; |
| 3 | } |
| 4 | |
| 5 | // Missing at launch: |
| 6 | function canSubmitForm(input, request, env) { |
| 7 | return input.inviteToken === env.INVITE_TOKEN; |
| 8 | } |
How To Mitigate
The mitigation moved protection to the mutation path and added enough telemetry to investigate future abuse without storing unnecessary personal data.
- Require the private-link token on every form submission.
- Accept the token from the JSON body, query string, or a private header for non-browser clients.
- Rate-limit submissions by source over a short rolling window.
- Record request metadata for accepted, rejected, invalid, and rate-limited attempts.
- Preserve a cleanup backup, delete synthetic rows, and verify the final response count.
| 1 | function hasSubmitAccess(input, request, env) { |
| 2 | const url = new URL(request.url); |
| 3 | const provided = |
| 4 | cleanText(input.inviteToken, 120) || |
| 5 | cleanText(url.searchParams.get("invite"), 120) || |
| 6 | cleanText(request.headers.get("x-invite-token"), 120); |
| 7 | |
| 8 | return provided === env.INVITE_TOKEN; |
| 9 | } |
| 10 | |
| 11 | async function handleSubmit(request, env, formSlug) { |
| 12 | const input = await request.json(); |
| 13 | const context = getRequestContext(request); |
| 14 | |
| 15 | if (!hasSubmitAccess(input, request, env)) { |
| 16 | await recordRequestLog(env, formSlug, "unauthorized", context); |
| 17 | return json({ error: "Private submission link required." }, { status: 403 }); |
| 18 | } |
| 19 | |
| 20 | if (await isRateLimited(env, formSlug, context)) { |
| 21 | await recordRequestLog(env, formSlug, "rate_limited", context); |
| 22 | return json({ error: "Too many attempts." }, { status: 429 }); |
| 23 | } |
| 24 | |
| 25 | const result = await saveSubmission(env, formSlug, input, context); |
| 26 | await recordRequestLog(env, formSlug, "saved", context, result.emailKey); |
| 27 | return json({ ok: true }); |
| 28 | } |
Audit Schema
The audit table should support incident reconstruction without becoming a second copy of the form data. It only needs the action type, status, timestamp, request source metadata, and a normalized row key when one is available.
| 1 | CREATE TABLE IF NOT EXISTS form_request_log ( |
| 2 | id TEXT PRIMARY KEY, |
| 3 | form_slug TEXT NOT NULL, |
| 4 | created_at TEXT NOT NULL, |
| 5 | action TEXT NOT NULL, |
| 6 | status TEXT NOT NULL, |
| 7 | ip_address TEXT, |
| 8 | user_agent TEXT, |
| 9 | edge_request_id TEXT, |
| 10 | request_country TEXT, |
| 11 | email_key TEXT, |
| 12 | metadata TEXT |
| 13 | ); |
| 14 | |
| 15 | CREATE INDEX IF NOT EXISTS idx_form_request_log_form_created |
| 16 | ON form_request_log (form_slug, created_at DESC); |
| 17 | |
| 18 | CREATE INDEX IF NOT EXISTS idx_form_request_log_ip_created |
| 19 | ON form_request_log (ip_address, created_at DESC); |
Cleanup
Cleanup should be reversible until verification is complete. The sequence is: export the affected rows, identify a deterministic synthetic pattern, delete only rows matching that pattern, then compare the remaining summaries with the expected known-good state.
| 1 | -- 1. Back up affected rows before deleting anything. |
| 2 | SELECT * |
| 3 | FROM form_submissions |
| 4 | WHERE form_slug = '<form>' |
| 5 | ORDER BY created_at; |
| 6 | |
| 7 | -- 2. Delete only deterministic synthetic rows. |
| 8 | DELETE FROM form_submissions |
| 9 | WHERE form_slug = '<form>' |
| 10 | AND email_key LIKE '%@example.com'; |
| 11 | |
| 12 | -- 3. Verify the remaining state. |
| 13 | SELECT |
| 14 | COUNT(*) AS responses, |
| 15 | SUM(CASE WHEN status = 'accepted' THEN quantity ELSE 0 END) AS accepted_count |
| 16 | FROM form_submissions |
| 17 | WHERE form_slug = '<form>'; |
Lessons
- Treat every browser-visible endpoint as public, even when the page is distributed through a private URL.
- Put authorization on the action, not only on the page that renders the action.
- Add rate limits before sharing links that write to persistent storage.
- Log rejected requests as well as accepted ones.
- Keep cleanup criteria narrow, deterministic, and backed up.
The durable rule is simple: hiding a page reduces discovery, but only the write endpoint can protect a write. If a route changes state, it needs its own token check, rate limit, and audit trail.