diff --git a/.changeset/otp-generate-csrf.md b/.changeset/otp-generate-csrf.md
new file mode 100644
index 0000000..2dfe5fb
--- /dev/null
+++ b/.changeset/otp-generate-csrf.md
@@ -0,0 +1,9 @@
+---
+"@seamless-auth/express": minor
+---
+
+Serve the OTP generate routes over POST instead of GET.
+
+`GET /auth/otp/generate-phone-otp`, `-email-otp`, and their `-login-` variants were state-changing routes (each sends an SMS or email) reachable as a simple cross-site request, so an `
` on any page could trigger unbounded OTP messages to a signed-in user. This is the same vector already closed for `/auth/magic-link`.
+
+BREAKING: the four `GET /auth/otp/generate-*` routes are removed and replaced with POST. Pair this with `@seamless-auth/react` 0.5.0 or later, which requests them over POST. An older SDK that still issues GET will get a 404.
diff --git a/packages/express/README.md b/packages/express/README.md
index 7eaef0d..5003545 100644
--- a/packages/express/README.md
+++ b/packages/express/README.md
@@ -175,11 +175,14 @@ Routes include:
- `DELETE /auth/logout` for the current session
- `DELETE /auth/logout/all` for every session owned by the current user
- `POST /auth/magic-link` to request a magic-link email
-
-`GET /auth/logout` and `GET /auth/magic-link` were removed. Both were
-state-changing routes reachable as simple cross-site requests (an `
` on
-any page could revoke every session or trigger magic-link emails). Use
-`DELETE /auth/logout/all` and `POST /auth/magic-link` instead.
+- `POST /auth/otp/generate-phone-otp`, `-email-otp`, and their `-login-` variants to send an OTP
+
+`GET /auth/logout` and `GET /auth/magic-link` were removed, and the four
+`GET /auth/otp/generate-*` routes are now POST only. All were state-changing
+routes reachable as simple cross-site requests (an `
` on any page could
+revoke every session, trigger magic-link emails, or send unbounded OTP SMS and
+email). Use `DELETE /auth/logout/all`, `POST /auth/magic-link`, and the POST
+generate routes instead.
**Options**
diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts
index e3fa431..a70cd52 100644
--- a/packages/express/src/createServer.ts
+++ b/packages/express/src/createServer.ts
@@ -341,16 +341,16 @@ export function createSeamlessAuthServer(
verifyLoginOtp(req, res, resolvedOpts, "email"),
);
- r.get("/otp/generate-phone-otp", (req, res) =>
+ r.post("/otp/generate-phone-otp", (req, res) =>
requestOtp(req, res, resolvedOpts, "phone"),
);
- r.get("/otp/generate-email-otp", (req, res) =>
+ r.post("/otp/generate-email-otp", (req, res) =>
requestOtp(req, res, resolvedOpts, "email"),
);
- r.get("/otp/generate-login-phone-otp", (req, res) =>
+ r.post("/otp/generate-login-phone-otp", (req, res) =>
requestOtp(req, res, resolvedOpts, "phone", "login"),
);
- r.get("/otp/generate-login-email-otp", (req, res) =>
+ r.post("/otp/generate-login-email-otp", (req, res) =>
requestOtp(req, res, resolvedOpts, "email", "login"),
);
diff --git a/packages/express/tests/loginOtpRoutes.test.js b/packages/express/tests/loginOtpRoutes.test.js
index ee1ae99..007ab7b 100644
--- a/packages/express/tests/loginOtpRoutes.test.js
+++ b/packages/express/tests/loginOtpRoutes.test.js
@@ -62,7 +62,7 @@ describe("login OTP routes", () => {
);
const res = await request(createApp())
- .get("/auth/otp/generate-login-email-otp")
+ .post("/auth/otp/generate-login-email-otp")
.set("Cookie", createPreAuthCookie());
expect(res.status).toBe(200);
@@ -105,4 +105,25 @@ describe("login OTP routes", () => {
}),
);
});
+ // The generate routes are state changing (they send an SMS or email). Serving
+ // them over GET made them reachable as a simple cross-site request, so an
+ //
tag could trigger unbounded sends to a signed-in user. They are now
+ // POST only. Carries the pre-auth cookie so the cookie middleware does not
+ // answer 400 before routing, which would pass whether or not the route exists.
+ it("no longer exposes the generate routes over GET", async () => {
+ for (const path of [
+ "/auth/otp/generate-phone-otp",
+ "/auth/otp/generate-email-otp",
+ "/auth/otp/generate-login-phone-otp",
+ "/auth/otp/generate-login-email-otp",
+ ]) {
+ const res = await request(createApp())
+ .get(path)
+ .set("Cookie", createPreAuthCookie());
+
+ expect([404, 405]).toContain(res.status);
+ }
+
+ expect(global.fetch).not.toHaveBeenCalled();
+ });
});