brain/decisions/000009-approval-links-require-a-post-confirmation-on-a-dedicated-route.md
Approval emails link to a dedicated /:id/waitpoints/:waitpointId/confirm route that serves an HTML confirmation page on GET/HEAD (never consuming the waitpoint), with Approve/Disapprove buttons; only the resulting POST resumes the flow. A single button replaces the two bare GET links across email, Telegram, Discord, and Teams, every channel whose buttons are browser url: links. The existing resume routes keep resuming on a bare GET, now marked @deprecated.
Paused flows resume via an unauthenticated, single-use endpoint whose only guard is an unguessable id (flowRunId + waitpointId). The approval pieces embedded two bare GET links in emails. Email security scanners (Microsoft Safe Links, Mimecast, Proofpoint) pre-fetch URLs with a GET before delivery, indistinguishable from a human click, so the prefetch consumed the waitpoint and could resume with an arbitrary outcome, while the human's later click saw "expired" (Pylon #5253, a regression since the 0.82.0 waitpoint rewrite).
A GET that never mutates is scanner-safe; only a deliberate POST decides the outcome. A new route is the surgical fix:
hooksFactory theme hook, so CE controllers never import the EE helper.New approval emails are scanner-safe: a prefetch renders the page but never resumes, and the human's single deliberate POST decides.
{ message } JSON), so programmatic callers are unaffected.