Stillwater

Security

Last updated 30 August 2026 · Covers the deployed app at stillwaterfishing.app

The short version

1. What is being protected

A fishing logbook is more sensitive than it first sounds. It contains where a person goes, when they are reliably there, and precise coordinates of spots they consider worth keeping. The two things worth real effort are: nobody should be able to read a logbook that is not theirs, and nothing should be published that the owner did not choose to publish.

2. Identity

Every row in the database carries a device_id — its scope. As of August 2026 there is one scope that matters, and one that is only kept alive to migrate people off it.

ScopeStrengthHow it is established
acct_<id> authenticated Real authentication Derived only on the server, from a Clerk session token verified against Clerk's JWKS on every single request. A client cannot assert one: the input validator rejects any client-supplied scope beginning with acct_, and when a valid token is present the client's requested scope is discarded entirely.
dev_<tag> legacy capability Weak — a per-browser tag A random per-browser id used before you sign in. Holding the string is the authorisation. It exists so a browser-local logbook can be adopted into an account (/api/adopt, which requires both a verified session and the old scope string). Nothing new is built on it.

The key_<hash> passphrase scope — a user-invented “sync code” hashed into a bucket id — was removed in August 2026. It was a second identity system with weaker recovery properties sitting beside a real one, and a passphrase that silently reasserted itself whenever a user signed out. Existing rows are reachable only by adopting them into an account.

3. How access is enforced

Enforcement is in the SQL, not in the UI. The relevant guarantees, endpoint by endpoint:

All queries use parameterised tagged templates, so values are never concatenated into SQL.

This design came out of a real finding. In an earlier version GET /api/outings accepted no scope and returned every row in the table — a live IDOR that exposed four separate logbooks including catch coordinates and private notes. It was fixed on 5 July 2026 by making the scope mandatory and pushing the ownership check into every statement. The account tier added on 16 August 2026 builds on that, and upgrades the strongest scope from a capability to a verified identity.

4. The leaderboard

This is the only place Stillwater shows one person's data to another, so it has three independent gates:

  1. Opt-in. accounts.leaderboard defaults to false. The query filters on it, so an account that has not opted in cannot appear even by accident.
  2. Account rows only. The query joins on account_id, which is set only for authenticated writes. Keyless capability-scoped outings are structurally unable to reach the board.
  3. Field allowlist. The SELECT names the columns it publishes, one by one. Notes, catch coordinates, gear, depth, trip times and the conditions snapshot are not in the list, so a future field added to a catch cannot leak by default. Timestamps are truncated to a date before they leave the server, because a precise time plus a named lake is a decent guess at where somebody was standing.

5. The league layer

Leagues are multi-tenant. Every competitive row — seasons, teams, measurement tools, entries, the audit log — carries a league_id, and eligibility lives on league_members, not on the account. “Membership: active” is a fact about a person in a league, so one angler can be an officer of one league and a lapsed member of another.

What in-app capture does and does not prove. Verification media is recorded through getUserMedia + MediaRecorder inside the app, which an ordinary member on an ordinary phone cannot feed from a saved file — the bar moves from “find any photo” to “defeat the browser”. It is not proof. Where a browser refuses a camera stream the app falls back to a camera-only file input and tags the upload honestly. Every file therefore carries two signals an officer can read on the entry: whether it came from the in-app camera at all, and how long after the file was created it was submitted.

6. The rest of the surface

7. Known weaknesses

Every system has these. Publishing them is more useful than implying they do not exist.

IssueImpactStatus
Weak dev_… tags The keyless per-browser tag is ~40 bits of randomness. It is private to that browser, but it is short enough that huge request volumes could eventually guess a live one. Account-scoped logbooks are not exposed this way — those need a valid signed token. mitigated /api/outings is rate-limited at the edge to 60 requests/minute per IP, before the function runs. See §7. Sign in for the stronger guarantee — an account scope cannot be guessed at all.
Photo URLs are public capabilities An uploaded photo or verification video lives in a public object store at an unguessable URL. Anyone given that link can view the image without signing in. Private storage was rejected deliberately: leaderboard photos and league verification media are displayed to other people by definition, and a signed-URL scheme would only move the capability, not remove it. accepted Documented in the Privacy Policy rather than overstated.
Verification media is evidence, not proof In-app capture raises the bar from “find any photo” to “defeat the browser”; a determined member can still defeat it, and the camera-roll fallback is a hint the browser is free to ignore. mitigated Every upload records whether it came from the in-app camera and how stale the file was at submission, and an officer sees both on the entry. Stated plainly rather than overclaimed.
Content-Security-Policy is static A CSP would reduce the blast radius if a script injection ever landed. open Wants care: the app loads MapLibre, Clerk, anime.js and astronomy-engine from CDNs, so the policy has to be built and tested, not guessed.
Client-side deletion is best-effort while offline Deleting an outing with no network removes it locally and queues the server delete. Until that request lands, the row still exists in the database. by design The alternative is refusing to delete anything offline.
Single maintainer, no formal audit This is a personal project. It has not been penetration-tested by anyone else. disclosed Judge the guarantees above accordingly.

8. Rate limiting

Every data endpoint is throttled. The limits are set well above what real use looks like, so you should never meet one:

EndpointLimitEnforced
/api/outings60 requests / minute per IPAt Vercel's edge, before any code runs
/api/photo20 uploads / hour per accountIn the function
/api/account30 requests / minute per accountIn the function
/api/leaderboard120 requests / minute per IPIn the function

The logbook endpoint gets the edge rule specifically because it is the only one where a request with no session can reach real data — by presenting a keyless capability scope. A throttle there has to be impossible to bypass, so it sits in front of the application rather than inside it. Exceeding a limit returns 429 with a Retry-After header; nothing is deleted or locked.

9. Reporting a vulnerability

Email arothe2007@gmail.com with enough detail to reproduce it. Please do not access, modify, or delete data belonging to anyone else while testing — use two accounts of your own. There is no bounty; there is a genuine thank-you and a fast fix.