diff --git a/.changeset/otp-generate-post.md b/.changeset/otp-generate-post.md
new file mode 100644
index 0000000..0b2b961
--- /dev/null
+++ b/.changeset/otp-generate-post.md
@@ -0,0 +1,9 @@
+---
+'@seamless-auth/react': minor
+---
+
+Request OTP generation over `POST` instead of `GET`.
+
+`requestPhoneOtp`, `requestEmailOtp`, `requestLoginPhoneOtp`, and `requestLoginEmailOtp` each caused an SMS or email to be sent, so they were state changing. Sent as a `GET`, they were simple cross-site requests, so an `
` on any page could trigger unbounded OTP messages to a signed-in user. They now POST an empty JSON body, which forces a CORS preflight and closes the vector. This mirrors the `requestMagicLink` change.
+
+BREAKING: this requires an adapter serving these routes over POST (`@seamless-auth/express` with the OTP POST change, released alongside this). Against an older adapter the four request methods get a 404. Callers need no code change.
diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts
index 7492af4..c713966 100644
--- a/src/client/createSeamlessAuthClient.ts
+++ b/src/client/createSeamlessAuthClient.ts
@@ -480,7 +480,13 @@ export const createSeamlessAuthClient = (
requestPhoneOtp: () =>
requestResult(
- fetchWithAuth(`/otp/generate-phone-otp`, { method: 'GET' }),
+ // POST with an empty JSON body on purpose. These routes send an SMS or
+ // email, so they are state changing. The JSON content type forces a CORS
+ // preflight, which keeps them from being triggered by a cross-site GET.
+ fetchWithAuth(`/otp/generate-phone-otp`, {
+ method: 'POST',
+ body: JSON.stringify({}),
+ }),
'Failed to send the SMS code.'
),
@@ -495,7 +501,13 @@ export const createSeamlessAuthClient = (
requestLoginPhoneOtp: () =>
requestResult(
- fetchWithAuth(`/otp/generate-login-phone-otp`, { method: 'GET' }),
+ // POST with an empty JSON body on purpose. These routes send an SMS or
+ // email, so they are state changing. The JSON content type forces a CORS
+ // preflight, which keeps them from being triggered by a cross-site GET.
+ fetchWithAuth(`/otp/generate-login-phone-otp`, {
+ method: 'POST',
+ body: JSON.stringify({}),
+ }),
'Failed to send the SMS code.'
),
@@ -510,7 +522,13 @@ export const createSeamlessAuthClient = (
requestEmailOtp: () =>
requestResult(
- fetchWithAuth(`/otp/generate-email-otp`, { method: 'GET' }),
+ // POST with an empty JSON body on purpose. These routes send an SMS or
+ // email, so they are state changing. The JSON content type forces a CORS
+ // preflight, which keeps them from being triggered by a cross-site GET.
+ fetchWithAuth(`/otp/generate-email-otp`, {
+ method: 'POST',
+ body: JSON.stringify({}),
+ }),
'Failed to send the email code.'
),
@@ -525,7 +543,13 @@ export const createSeamlessAuthClient = (
requestLoginEmailOtp: () =>
requestResult(
- fetchWithAuth(`/otp/generate-login-email-otp`, { method: 'GET' }),
+ // POST with an empty JSON body on purpose. These routes send an SMS or
+ // email, so they are state changing. The JSON content type forces a CORS
+ // preflight, which keeps them from being triggered by a cross-site GET.
+ fetchWithAuth(`/otp/generate-login-email-otp`, {
+ method: 'POST',
+ body: JSON.stringify({}),
+ }),
'Failed to send the email code.'
),
diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts
index b40595f..4785f30 100644
--- a/tests/createSeamlessAuthClient.test.ts
+++ b/tests/createSeamlessAuthClient.test.ts
@@ -103,9 +103,7 @@ describe('createSeamlessAuthClient', () => {
expect(mockFetchWithAuth).toHaveBeenNthCalledWith(
1,
'/otp/generate-login-phone-otp',
- {
- method: 'GET',
- }
+ { method: 'POST', body: JSON.stringify({}) }
);
expect(mockFetchWithAuth).toHaveBeenNthCalledWith(2, '/otp/verify-login-phone-otp', {
method: 'POST',
@@ -113,6 +111,31 @@ describe('createSeamlessAuthClient', () => {
});
});
+ it('requests OTP generation over POST so the routes are not simple GETs', async () => {
+ mockFetchWithAuth.mockResolvedValue({
+ ok: true,
+ json: async () => ({ message: 'Success' }),
+ });
+
+ const client = createSeamlessAuthClient({
+ apiHost: 'https://api.example.com',
+ });
+
+ await client.requestPhoneOtp();
+ await client.requestEmailOtp();
+ await client.requestLoginPhoneOtp();
+ await client.requestLoginEmailOtp();
+
+ // A bodyless GET would be a simple cross-site request. POST with a JSON body
+ // forces a preflight, which is the whole point of the change.
+ for (const [path] of mockFetchWithAuth.mock.calls) {
+ expect(path).toMatch(/\/otp\/generate-/);
+ }
+ for (const [, init] of mockFetchWithAuth.mock.calls) {
+ expect(init).toEqual({ method: 'POST', body: JSON.stringify({}) });
+ }
+ });
+
it('uses login-specific email OTP endpoints', async () => {
const response = { ok: true };
mockFetchWithAuth.mockResolvedValue(response);
@@ -127,7 +150,7 @@ describe('createSeamlessAuthClient', () => {
expect(mockFetchWithAuth).toHaveBeenNthCalledWith(
1,
'/otp/generate-login-email-otp',
- expect.objectContaining({ method: 'GET' })
+ { method: 'POST', body: JSON.stringify({}) }
);
expect(mockFetchWithAuth).toHaveBeenNthCalledWith(2, '/otp/verify-login-email-otp', {
method: 'POST',