From a5bf59cbbd549402dbc5692ef12c243f1895d9d4 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 20 Jul 2026 10:01:31 -0400 Subject: [PATCH] fix(client)!: request magic links over POST GET /auth/magic-link was a state-changing route reachable as a simple cross-site request, so an img tag on any page could trigger unbounded magic-link emails to a signed-in user. The Express adapter removed the GET route in favor of POST. The request carries an empty JSON body. The adapter ignores it, but the resulting JSON content type forces a CORS preflight, which is what keeps the route from being reachable cross-site. A bodyless POST would still be a simple request and would leave the vector open. BREAKING CHANGE: requires an adapter serving POST /auth/magic-link (@seamless-auth/express 0.9.0 or later). Against an older adapter, requestMagicLink gets a 404. Callers need no code change. Refs #93 --- .changeset/magic-link-post.md | 11 +++++++++++ src/client/createSeamlessAuthClient.ts | 5 ++++- tests/createSeamlessAuthClient.test.ts | 23 ++++++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 .changeset/magic-link-post.md diff --git a/.changeset/magic-link-post.md b/.changeset/magic-link-post.md new file mode 100644 index 0000000..f6c5c94 --- /dev/null +++ b/.changeset/magic-link-post.md @@ -0,0 +1,11 @@ +--- +'@seamless-auth/react': minor +--- + +Request magic links over `POST /auth/magic-link` instead of `GET`. + +`GET /auth/magic-link` was a state-changing route reachable as a simple cross-site request, so an `` on any page could trigger unbounded magic-link emails to a signed-in user. The Express adapter removed the GET route and replaced it with POST. + +The request now carries an empty JSON body. The adapter ignores it, but the resulting JSON content type forces a CORS preflight, which is what actually keeps the route from being reachable cross-site. A bodyless POST would still be a simple request. + +BREAKING: this release requires an adapter with the POST route (`@seamless-auth/express` 0.9.0 or later). Against an older adapter, `requestMagicLink` gets a 404. Callers of `requestMagicLink()` need no code change. diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index 7af2058..7492af4 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -538,9 +538,12 @@ export const createSeamlessAuthClient = ( 'Failed to verify the email code.' ), + // Sends an empty JSON body on purpose. The adapter ignores it, but it makes + // fetchWithAuth declare a JSON content type, which forces a CORS preflight. + // A bodyless POST is still a simple request and stays reachable cross-site. requestMagicLink: () => requestResult( - fetchWithAuth(`/magic-link`, { method: 'GET' }), + fetchWithAuth(`/magic-link`, { method: 'POST', body: JSON.stringify({}) }), 'Failed to send the magic link.' ), diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts index e540ed1..b40595f 100644 --- a/tests/createSeamlessAuthClient.test.ts +++ b/tests/createSeamlessAuthClient.test.ts @@ -135,6 +135,26 @@ describe('createSeamlessAuthClient', () => { }); }); + // A GET here was CSRF-able: an img tag on any page triggered magic-link + // emails to the victim. The JSON body is what forces the preflight. + it('requests a magic link over POST with a content type that forces a preflight', async () => { + mockFetchWithAuth.mockResolvedValue({ + ok: true, + json: async () => ({ message: 'Success' }), + }); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + }); + + expect((await client.requestMagicLink()).error).toBeNull(); + + expect(mockFetchWithAuth).toHaveBeenCalledWith('/magic-link', { + method: 'POST', + body: JSON.stringify({}), + }); + }); + it('keeps an untrusted magic-link token inside its own path segment', async () => { mockFetchWithAuth.mockResolvedValue({ ok: true, @@ -263,7 +283,8 @@ describe('createSeamlessAuthClient', () => { method: 'DELETE', }); expect(mockFetchWithAuth).toHaveBeenNthCalledWith(2, '/magic-link', { - method: 'GET', + method: 'POST', + body: JSON.stringify({}), }); });