Skip to content

fix(magic-link): issue one session per link, and confirm links opened on another device - #449

Merged
Bccorb merged 4 commits into
mainfrom
fix/magic-link-redemption
Oct 18, 2026
Merged

Bccorb merged 4 commits into
mainfrom
fix/magic-link-redemption

Conversation

@Bccorb

@Bccorb Bccorb commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

Fix for GHSA-9jg2-jjqq-3346. Reviewed privately in the advisory fork.

What changes

  • Each link records the requesting ephemeral token's jti. GET /magic-link/check only finds links for the polling attempt, and issues the session once (redeemed_at, set atomically). Later checks for the same attempt get 200 { message: 'Success' } without tokens, so a second tab in the same browser still completes from the cookie it already has.
  • GET /magic-link/verify/{token} consumes the link only on the requesting device: a forwarded ephemeral bearer whose jti matches, otherwise the IP and user agent a trusted adapter forwarded with its service token. Anywhere else it answers 409 confirmation_required with the requesting platform and time, and leaves the link unused. Mail scanners and links someone else requested no longer complete a sign-in.
  • New POST /magic-link/verify/{token}/confirm consumes the link after the person who opened it approves.
  • New auth event magic_link_confirmation_required. Migration adds attempt_id, requested_device_class and redeemed_at.
  • Minor changeset. Contract change, ripple below.

Checks

  • typecheck, lint, format: pass
  • npm run coverage: 136 files, 1826 passed, 1 todo. Statements 98.91%, branches 95.38%, functions 99.27%, lines 99.15%
  • Migration up, down and up again on Postgres 16 (scratch database, dropped after)

Ripple (not in this PR)

All adapters (Express, Fastify, Next.js, Go, Python, Rust) pick up the confirm route from the manifest. Each needs to forward the browser's pre-auth credential on verify (optional, never 401). The client needs confirmMagicLink and a preAuth identity on verify; the React, Angular, Vue and Svelte verify screens need a confirm step; types, docs and the Next.js template follow. Old SDKs show the 409 as an error, so cross-device links fail closed until they upgrade; same-device links keep working.

Part of the v0.19 security release (#385). Rebased onto main after #440 to #446; 138 files, 1870 tests pass locally.

…ested it

Bind each magic link to the requesting ephemeral token's jti and redeem it
once. Later checks for the same attempt are told the sign-in is complete
without receiving another session.
…r device

Verify consumes the link only on the requesting device, proven by the
attempt's ephemeral token when the adapter forwards it, otherwise by IP
and user agent. Elsewhere it answers 409 confirmation_required without
consuming the link, and POST /magic-link/verify/:token/confirm consumes it
once the person who opened it approves.
@Bccorb Bccorb added the v0.19 Targets the v0.19 release label Oct 18, 2026
@Bccorb Bccorb added this to the v0.19 milestone Oct 18, 2026
@Bccorb
Bccorb merged commit a894fa1 into main Oct 18, 2026
7 of 8 checks passed
@Bccorb
Bccorb deleted the fix/magic-link-redemption branch October 18, 2026 13:36
@Bccorb Bccorb mentioned this pull request Oct 18, 2026
5 of 36 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v0.19 Targets the v0.19 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant