There is a tradition on cruise ships where passengers hide decorated rubber ducks for other passengers to find. This gives each duck a QR code, so finding one becomes the start of a record rather than the end of it. Scan the duck, log the find, upload a photo, and see everywhere that duck has already been.
I built it over a weekend, for a specific trip I was about to take. That is the honest scope: a private project with a deadline set by a departure date, not a product.
The moderation problem
The one part I did think properly about up front.
Any system that lets anonymous members of the public upload photos will, eventually, receive something you do not want on a family cruise. Manual review does not work — finds happen at odd hours, and a photo that sits unapproved for a day may as well not have been uploaded.
AWS Rekognition handles the first pass: safe photos are approved automatically and inappropriate content is blocked before it is ever visible. An admin dashboard handles the ambiguous middle, so a human decides only the cases a machine should not.
Everything else: auto-generated duck IDs (Pirate becomes PIRATE01), an interactive world
map of finders, cruise line and ship tracking so a duck accumulates a real itinerary,
geocoded location verification rather than trusting free-text city entries, and a
mobile-first build, because every scan happens on a phone over ship wifi.
Opening it up, and checking first
The repository was private from the day I created it. When I decided to put this project on my portfolio, the first thing I did was scan its full history for anything I should not be publishing — before flipping the switch, not after.
That check found an active AWS access key and secret I had committed myself in November
2025, in apprunner.yaml and two markdown guides, across 31 of 34 commits.
Being private for that whole period genuinely bounds the exposure: no anonymous scanner ever saw it, and access was limited to me. It is the difference between a mistake and an incident, and I am not going to pretend otherwise. What it does not do is make the credentials safe. Private repositories get cloned, forked, shared with collaborators, and occasionally made public by mistake — which is precisely the transition I was about to make.
Running the audit before publishing is the process working. Needing it to find my own key is the part I would rather not have needed.
The detail that bothers me most
One of the files holding the key was UPDATE_ADMIN_PASSWORD.md, and it instructed the reader
to paste secrets into apprunner.yaml and commit them — with the note “make sure your
repository is private!”
A one-off mistake under time pressure is a mistake. I had written the shortcut down as a procedure, which meant I was going to keep repeating it.
What the key could do, and what I cannot prove
It belonged to an IAM user scoped to s3:GetObject, PutObject, DeleteObject on the image
bucket, s3:ListBucket, and rekognition:DetectModerationLabels. Bounded — no IAM, no
account-wide access — but enough to read, overwrite or delete every duck photo and record.
Whether it was ever misused, I could not determine, and the reason is worth knowing:
CloudTrail logs S3 management events by default, not object-level data events. A key used
only for GetObject and PutObject leaves no default audit trail. Ninety days of CloudTrail
showed no management events for it, which is equally consistent with normal use and with
undetected abuse. AccessKeyLastUsed is coarse and lagging, so it settles nothing either.
Given the repo was private throughout, misuse is unlikely. But the honest answer is “unknown,” not “it was fine,” and I would rather say so. The fix for next time is enabling S3 data events on the bucket, which I have done.
What I changed
I deactivated the key first rather than deleting it — same security outcome immediately, reversible in seconds if something undiscovered depended on it. Then I migrated the application to an App Runner IAM instance role, confirmed nothing broke, deleted the key, and removed the orphaned user and policy.
Auditing properly turned up more than the credentials, which is the usual result of actually looking:
- Three unauthenticated write endpoints.
POSTandDELETEon/api/duck/:duckIdand the sighting delete had no auth check. Admin-only equivalents existed alongside them, so these were older routes never removed when authentication was added. - Stored XSS into the admin dashboard. User values were interpolated raw into
innerHTML. The serious path: feedback is submitted anonymously and rendered inside an authenticated admin page, so a visitor could store markup that executed with admin privileges the next time I opened the dashboard. - A default admin password (
duckadmin123) as a code fallback, a session secret in the repo, and the admin password printed to CloudWatch on every boot.
All fixed: scrypt hashing with constant-time comparison, secrets from Secrets Manager, a server that refuses to start without them rather than defaulting, session regeneration on login, and context-appropriate escaping at every render site.
Why the secrets are still in the git history
They are, and I decided not to rewrite it.
Rotation is the fix that matters. With the key deleted at AWS and the passwords replaced, every secret in that history is inert — it authenticates nothing. A credential that grants no access is a string. Rewriting history would not recall the data either: anyone who cloned the repo, and any cache that fetched those objects, still has them. It protects against future readers only, which rotation has already made pointless.
So the history stays and this page explains it, rather than a force-push quietly making it look like it never happened.
The limits matter though: if a leaked credential ever cannot be rotated, none of this applies and the history has to be purged. And publishing a repo with dead credentials in it still discloses an account ID and bucket names, and will trip secret scanners. Accepted knowingly, in exchange for the record being honest.
What I take from it
Security should have been built in from the start, and it was not. It was a weekend, the ship was leaving, and I made the trade most people make under that pressure — then left it in place for nine months because the thing worked and I had stopped looking at it.
What I would defend is the order of operations at the end: I checked before I published, rather than finding out afterwards. And the fixes that stuck were structural, not resolutions to be careful. No static keys anywhere, instance roles instead of credentials, a server that refuses to boot rather than falling back to a default, secret scanning that blocks the push instead of reporting it later. The client work described elsewhere on this site uses OIDC federation and holds no long-lived keys at all — that is not a coincidence, it is where this went.
What is next for it
It has sat mostly idle since that trip. I want to take it further for the next cruise — more of an actual product and less of a weekend hack, now that the security groundwork underneath it is real rather than assumed.
The full remediation write-up lives in docs/SECURITY_REMEDIATION_2026-09.md in the
repository.