Build1 publisher3 min readPublished
A case-insensitive name match lets anyone overwrite an anonymous poll response
A dev.to walkthrough swaps the participant name out of a delete filter and puts 32 random bytes in an HttpOnly cookie, keeping only the SHA-256 digest in the database. The added cost is one column and one hash.
The Engineer · Build desk

What happened
- A dev.to post on anonymous editing shows a Prisma deleteMany that locates a guest's poll response by poll id and a case-insensitive match on the participant's display name.
- Its replacement issues a cryptographically random token to the guest browser and keeps only that token's SHA-256 hash in the database.
- The cookie carrying the token is set httpOnly, so page JavaScript cannot read it, with sameSite lax and secure enabled in production.
- Signed-in participants follow a separate path, where the server takes the user id from the active session and refuses any user id sent in the request body or query parameters.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure Every anonymous response in a poll built this way is reachable by anyone who can see the participant list, and the insensitive comparison means an attacker does not need the capitalisation right.
- cost The team adopting the token pays a column, a cookie write at create time and a hash per edit request; participants pay nothing until the day they clear their cookies.
- constraint Edit rights sit in one browser profile, so a participant who switches devices loses access to their own response and the server holds nothing that could restore it.
- decision Anyone copying the pattern has to pick the cookie lifetime, and the 90 days in the post keeps an edit window open for a quarter of a year on any machine that still has the cookie.
The requester supplies exactly one field in that delete filter, and it is the field printed beside their response for every other participant to read [1][4]. The warning in the write-up is blunt: anyone who knows or guesses the participant's name could overwrite that person's response [2]. The principle is stated in one line: "It should not grant permission to edit that response." [3]
The replacement credential is `randomBytes(32).toString("base64url")`, which is 256 bits of entropy in a 43-character string [7][19]. The cookie is set with `httpOnly`, `sameSite: "lax"`, `secure` in production, and `path: "/"` [8]. `maxAge` is written as `60 * 60 * 24 * 90`. That is 7,776,000 seconds, or 90 days [18].
Only the hex digest from `createHash("sha256")` goes into the database [6][11]. That fixes the shape of the read path: the server hashes the cookie value it receives and matches the row on the digest [20]. Hashing is justified in the post on the grounds that a raw token is a bearer credential, and that an attacker with database read access could copy stored tokens and edit active responses [12].
Adoption is cheap. You add a column for the digest, one cookie write when the response is created, and one hash per edit request [24].
Where it breaks is the storage location. The credential lives in the browser, so edit rights follow the cookie jar and not the person; a shared machine hands the next user of that browser the same rights for the remainder of the window [23]. Because only the digest is persisted, a cleared cookie cannot be restored and the participant has no way to demonstrate that the row was theirs [21]. On XSS the post does not overclaim: `httpOnly` reduces direct token exfiltration, and it does not remove every XSS risk [9].
The signed-in path is the boring half, and boring is what you want in an authorization check. The user id comes from the active session and never from the request body or query parameters [13], and an upsert on the composite key of `pollId` and `userId` holds one participant row per account [14]. The display name is then data on the row, and permission depends on the immutable `userId` [15].
That constraint does not reach guests. The unsafe query matches rows whose `userId` is null [1], so `pollId_userId` cannot key an anonymous participant [22]. Any uniqueness guests need has to come from another column.
For the number to transfer, one thing has to be true of your traffic: the person who submitted the response comes back in the same browser inside the 90-day window [8][23]. In a poll circulated among colleagues on their own laptops, that mostly holds. For a survey link forwarded through a mailing list, or filled in on a shared terminal, the token sits on a machine the participant may never touch again.
What to watch
- A published verification snippet showing an indexed lookup on the token hash would settle what uniqueness key guest rows need.
- A maxAge shorter than 90 days, or token rotation on each edit, would narrow how long a copied cookie stays valid.
- Whether poll and survey starter templates keep matching anonymous rows on the display name at all.