Skip to content

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

  1. EventFan-out to email, push, and an in-app row
  2. notify_inapp()Notification row, detached after commit
  3. New-message emailtoken = notification_id + HMAC, valid 30 days, no new table
  4. /reply/<token>No-login mini-chat page
  5. record_replyPosts into the thread
The notification row doubles as the reply anchor, so the feature needed no new tables or migrations.

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. 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. 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. 3.Detached ORM rows

    Notification rows are expunged before commit, so callers read ids only and never refresh them.

  4. 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.

Need a backend engineer who owns it end to end?

From architecture to production support, I take features from requirements to running software.