Case study
Notification Audit and Reply-by-Link
Closed a critical link-poisoning vulnerability and turned message emails into something users can reply to without logging in.
- Project
- Online tutoring platform
- Industry
- EdTech / SaaS
- Role
- Audit, security fixes, notification centre, reply path
- Python
- FastAPI
- HMAC-signed tokens
- Redis
- Alembic
- PostgreSQL
- Next.js
- TypeScript
- pytest
- Vitest
The context
An audit of every email, push, and in-app notification the platform sends, followed by fixes, an in-app notification centre, and a no-login "reply from the email" page for chat messages.
The problem
The audit found 49 distinct issues. The worst:
- Poisonable links: password-reset, verify, and invite links were built from the request's Origin header, and production accepted any host. An attacker could trigger a genuine email whose link pointed at their own domain and carried the reset code.
- Silent gaps: parent and tutor self-bookings sent no notification at all, and invoice events were push-only.
- Misleading signals: two jobs shared the same "overdue" subject, one linking to a route that didn't exist, and one org setting was never read.
- A fake notification centre: the dashboard bell only counted unread threads.
- A reply gap: reading a message notification meant logging in.
My responsibility
- Ran the audit against the code, not the docs
- Fixed the security and routing defects
- Built the notification centre and the reply path
- Incorporated review findings
Architecture
- EventFan-out to email, push, and an in-app row
- notify_inapp()Notification row, detached after commit
- New-message emailtoken = notification_id + HMAC, valid 30 days, no new table
- /reply/<token>No-login mini-chat page
- record_replyPosts into the thread
Key decisions
Never derive links from request headers
Links come from configured frontend-origin settings, and the CSRF origin check trusts the same configured value.
The notification row is the reply anchor
A signed token over its id means zero new tables or migrations.
Scope cut deliberately
A full inbound-email path (webhook, reply-to addresses, mail parsing) was built first. When the product owner clarified that the signed link was the feature, the whole inbound path was deleted and every other email restored to its previous form.
A template-catalogue test
It renders every email and verifies every link against the web app's real routes.
Technical challenges
1.Reviewing my own work
An adversarial review of the diff found 32 verified issues, including an open redirect through a tab character in a ?next= parameter, a quoted-reply stripper that dropped any reply starting with "On", and a quadratic HTML parse on an unauthenticated endpoint. All were fixed.
2.Marketing CSS leaking
Global !important heading rules hit top-level routes outside the app's style scope, so the reply page looked broken.
3.Detached ORM rows
Notification rows are expunged before commit, so callers read ids only and never refresh them.
4.Local end-to-end proof
All suppressed sends were captured to disk and a local SMTP sink proved the debounced email actually lands with a working link.
Results
- Critical link-poisoning vulnerability closed: [merged and deployed date]
- Every notification template and link covered by an automated catalogue test
- 49 audited issues → [X] fixed, [Y] consciously deferred
- Reply rate to message emails: [X%], median time-to-reply [X hours → Y]
What I learned
- Audit the code, not the documentation. The docs were stale in about ten places.
- Ask what the user actually wants before building infrastructure. A signed link replaced an entire mail-parsing pipeline.
- Review your own big diffs adversarially. It found more bugs than the feature tests did.