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({}),
});
});