Security
The short version
- Every read, write, and delete is scoped. There is no unscoped endpoint that returns other people's data.
- A signed-in request's identity comes from a server-verified session token, never from a value the client sends.
- The leaderboard is the only endpoint that shows one person's data to another, and it uses a field allowlist, not a blocklist.
- Known gaps are listed below in §6 rather than left for you to discover.
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.
| Scope | Strength | How 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:
GET /api/outingsrequires a scope and filterswhere device_id = $scope. There is no "list everything" path — a request with no scope is a400, not an empty filter.POST /api/outingsupserts with… on conflict (id) do update … where outings.device_id = $scope. Guessing another person's outing id does not let you overwrite it; the update silently matches zero rows.DELETE /api/outingsdeleteswhere id = $id and device_id = $scope. Ownership is part of the statement.GET /api/account,PUT /api/accounttake no account parameter at all. The row acted on is derived from the verified token, so there is nothing to tamper with.POST /api/photorequires a verified session, checks the file's actual magic number rather than its claimed MIME type, caps the size, and chooses the storage path itself under the caller's own user id.
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:
- Opt-in.
accounts.leaderboarddefaults tofalse. The query filters on it, so an account that has not opted in cannot appear even by accident. - 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. - 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.
- No cross-tenant reads. Every authenticated route resolves a league first, checks the caller's membership in it, and filters every query on it. A request for a league you have not joined is refused, not silently narrowed.
- Joining is always code-gated. Visibility and access are separate questions: a league can choose to be publicly listed and readable, but entering fish in it always requires a join code an officer issues and can rotate. Join attempts are rate limited per account.
- Officer bootstrap. Each league has its own officer code, generated at creation and shown only to its officers. Five attempts per account per league per hour.
- Entries are append-only. A correction inserts a new version and links it to the old one; the only
writes to an existing row are the
superseded_bypointer and the officer status block. Officers change an entry's status, never its facts, and every action is written to an append-only audit log with a reason. - Verification media is uploaded only with a verified session, validated by magic number rather than a claimed content type, size-capped separately for stills and video, and written to a server-chosen path under the caller's own user id — so no client can pick a path and overwrite another member's evidence.
- Precise coordinates are selected only on the officer path and the owner's own path. Public standings return the waterbody name and nothing else about location. The research export withholds coordinates row by row for every member who has not consented, and drops handles entirely.
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
- Secrets.
CLERK_SECRET_KEY,POSTGRES_URLandBLOB_READ_WRITE_TOKENexist only as Vercel environment variables, are read server-side, and are never returned by any endpoint./api/configserves the Clerk publishable key, which is public by design..env*is git-ignored. - Passwords. Stillwater never sees one. Sign-in, session issuance, resets and MFA are Clerk's responsibility.
- XSS. User-supplied strings are escaped before being interpolated into HTML, and photo references are validated as
https:URLs on the way into the database. - CORS. Cross-origin access is restricted to the project's own deployment origins. Same-origin app calls are unaffected.
- Location. Device GPS is requested one fix at a time, only on an explicit tap. There is no
watchPositionand no background tracking, and a fix is never sent to a Stillwater server. - Photo metadata. Images are re-encoded through a canvas in the browser before upload, which discards all EXIF — including the GPS tag phones embed. The upload endpoint therefore receives an image with no embedded location.
- Destructive actions. Clearing the logbook requires typing
CLEAR; deleting an outing requires confirming which one.
7. Known weaknesses
Every system has these. Publishing them is more useful than implying they do not exist.
| Issue | Impact | Status | |
|---|---|---|---|
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:
| Endpoint | Limit | Enforced |
|---|---|---|
/api/outings | 60 requests / minute per IP | At Vercel's edge, before any code runs |
/api/photo | 20 uploads / hour per account | In the function |
/api/account | 30 requests / minute per account | In the function |
/api/leaderboard | 120 requests / minute per IP | In 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.