Anonymous Photo Uploads Without Becoming a Free File Host
How ReportIt accepts incident reports — photos included — from people with no accounts, using layered spam defenses and a proof-of-work-style upload authorization.
ReportIt started as a favor to my own neighborhood association: a small system where neighbors could report broken streetlights and graffiti, and volunteer board members could track what happened to those reports. It has since grown into a multi-tenant product — Rust on ARM64 Lambda, DynamoDB, S3, SES, a SvelteKit frontend — but the design decision that shaped almost everything else was made on day one: reporters never create accounts.
The reasoning is unromantic. A neighbor who just photographed a fallen tree branch will not complete a signup flow. Every screen of friction between "I saw a thing" and "the association knows about it" loses reports, and lost reports are the whole failure mode of a system like this. So identity is just an email address, verified after the fact: submitting a report sends a confirmation link, and the report only becomes real when the link is clicked. Unconfirmed reports are written with a DynamoDB TTL and quietly evaporate after seven days — no cleanup job, no cron, the database just forgets them.
What that decision costs you is every abuse control that authentication normally provides for free. A public POST endpoint that writes to your database and sends email on your behalf is a magnet, so the submission path stacks defenses that each catch a different class of abuser. A honeypot field — named website, invisible to humans — catches naive bots, and when it trips the API returns a fake success with a fake report ID rather than an error, so the bot learns nothing. A form-timing check rejects submissions completed in under five seconds, since no human fills out an incident report that fast. Per-IP and per-email rate limits are enforced in DynamoDB. A captcha (Turnstile, with hCaptcha as the fallback) sits in front of it all. And addresses that have bounced or filed complaints land on a suppression list; submissions from them also get the fake-success treatment, plus an audit log entry so an admin can see what was silently dropped. None of these is clever individually. The point is that each layer emits a metric when it fires, so I can see which defenses are actually earning their keep.
The genuinely hard endpoint was photo upload. Photos matter — a report about graffiti without a photo is a shrug — and they're too big to shove through Lambda comfortably, so they go straight to S3 via presigned URLs. For the experience to feel right, uploads need to start while the reporter is still typing their description, which means the upload endpoint runs before the report exists, before the captcha is solved, before the email is confirmed. It is the least-verified moment in the entire flow, and it hands out write access to an S3 bucket. Left naked, that's a free file host with my name on the bill.
The defense I landed on is to make asking expensive. Before requesting its first upload URL, the browser derives an authorization token: PBKDF2-SHA256 at 100,000 iterations over email|hostname|timestamp, computed with WebCrypto. On a typical phone that's on the order of a second of CPU — a delay a legitimate reporter never notices, because it overlaps with them filling out the form. The server re-derives the same value from the claimed inputs and compares in constant time. The timestamp has to be within the last fifteen minutes, so tokens can't be stockpiled ahead of time or replayed later, and the binding to a specific email feeds the per-email rate limits on top.
I've been calling this proof of work, and a pedant would object: there's no nonce lottery, no adjustable difficulty, no search. It's key stretching worn as a work receipt — a deterministic computation that both sides can perform, where the security property isn't secrecy but cost. And that's really all the situation calls for. The attacker I'm pricing out isn't a nation-state; it's a script that would otherwise mint ten thousand upload sessions in the time it takes mine to mint one. Hashcash-style PoW would let the server verify cheaply with one hash, which is elegant, but I chose the dumber symmetric scheme because the client-side implementation is a single WebCrypto call — no worker pools, no difficulty tuning, nothing to get wrong in JavaScript.
The symmetric cost does create a real wrinkle: verification costs the server the same 100,000 iterations, and on Lambda, CPU time is literally the bill. Worse, one report can carry several photos, each needing its own presigned URL, and re-verifying per request would multiply the cost and hand attackers a cheap way to burn my compute. So verification is memoized: the first valid request pays the PBKDF2 price, and the verified token is cached in DynamoDB along with a freshly pre-assigned report ID. Every subsequent upload request with that token is a cache lookup. The cost asymmetry that hashcash gets from math, I get from a database — less elegant, equally effective.
The pre-assigned report ID turned out to be a nice structural bonus. Photos upload directly into reports/{id}/ before the report exists, so nothing has to move when the form is finally submitted. Submission closes the loop: the request must present the same authorization token, the email must match the one the token was minted for, a pending-session record must still exist server-side, and every claimed photo key must sit under the pre-assigned prefix — so you can't attach someone else's uploads to your report by guessing keys.
Is any of this unbeatable? Of course not. Someone determined can pay the CPU cost, solve the captchas, rotate IPs, and get garbage into the queue — at which point the rate limits cap the damage, the audit trail shows what happened, and a human admin does what human admins do. The goal was never to build an impenetrable system; it was to let a neighbor with a photo of a pothole and thirty seconds of patience file a report, while making the economics of drive-by abuse not worth anyone's time. So far, the layers are holding.
If you want to feel the cost model yourself, the project page has an interactive demo of the derivation — pick an iteration count, mint a token, and see what bulk-minting spam sessions would cost your own hardware. The product itself lives at reportit.app.