From a57e6ba041534710b5d0c5d06a0d2e6ed8bd0e1c Mon Sep 17 00:00:00 2001 From: Arseniy Shishaev <35579920+arseniycodes@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:00:23 +0000 Subject: [PATCH] Block sign-ups from configured email domains Adds SIGNUP_BLOCKED_EMAIL_DOMAINS, a comma-separated list of email domains (and their subdomains) that may not create accounts. Enforced in Better Auth's user.create.before hook, so it covers email/password, social, and admin-created users alike. Blocked attempts get a 403 and no user row is written. Unset = nothing blocked. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/api/src/auth.ts | 15 +++++++++ apps/api/src/signup-email-domains.test.ts | 39 +++++++++++++++++++++++ apps/api/src/signup-email-domains.ts | 22 +++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 apps/api/src/signup-email-domains.test.ts create mode 100644 apps/api/src/signup-email-domains.ts diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index d85bc3c2..92e67649 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -8,6 +8,7 @@ import { import { autumn } from "autumn-js/better-auth"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { APIError } from "better-auth/api"; import { admin, organization } from "better-auth/plugins"; import { eq } from "drizzle-orm"; import { favoriteProjectToSeed, pickActiveOrgId as pickActiveOrgIdRule } from "./active-context.js"; @@ -20,6 +21,7 @@ import { verificationEmailBody, } from "./email.js"; import { readSignupAttributionFromCookieHeader } from "./signup-click-ids.js"; +import { blockedSignupEmailDomains, isSignupEmailBlocked } from "./signup-email-domains.js"; import { enqueueUserCreated } from "./user-created-publisher.js"; import { configuredWebOrigins } from "./web-origins.js"; @@ -68,6 +70,8 @@ function sharedCookieDomain(): string | undefined { const COOKIE_DOMAIN = sharedCookieDomain(); +const BLOCKED_SIGNUP_EMAIL_DOMAINS = blockedSignupEmailDomains(); + // Autumn billing plugin. Maps each org (a Better Auth "organization") to an // Autumn customer, auto-created on first sign-in, so billing is per-org. Only // enabled when AUTUMN_SECRET_KEY is present, so local dev / worktrees without @@ -273,6 +277,17 @@ export const auth = betterAuth({ }, user: { create: { + // Runs before every new users row — email, social, and admin-created + // alike — so a blocked domain can't slip in through an OAuth provider. + before: async (user) => { + if (isSignupEmailBlocked(user.email, BLOCKED_SIGNUP_EMAIL_DOMAINS)) { + console.warn("[auth] rejected sign-up from blocked email domain"); + throw new APIError("FORBIDDEN", { + message: "Sign-ups from this email domain are not allowed.", + }); + } + return { data: user }; + }, after: async (user, context) => { // Fires on every new users row — email and social signups alike, and // users who never finish onboarding — so the signup count is captured diff --git a/apps/api/src/signup-email-domains.test.ts b/apps/api/src/signup-email-domains.test.ts new file mode 100644 index 00000000..4f1cd114 --- /dev/null +++ b/apps/api/src/signup-email-domains.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { blockedSignupEmailDomains, isSignupEmailBlocked } from "./signup-email-domains.js"; + +test("no domains are blocked unless the deployment configures some", () => { + assert.deepEqual(blockedSignupEmailDomains({}), []); + assert.deepEqual(blockedSignupEmailDomains({ SIGNUP_BLOCKED_EMAIL_DOMAINS: " , " }), []); + assert.equal(isSignupEmailBlocked("someone@example.com", []), false); +}); + +test("parses a comma-separated, case-insensitive domain list", () => { + assert.deepEqual( + blockedSignupEmailDomains({ SIGNUP_BLOCKED_EMAIL_DOMAINS: " Spam.example ,@junk.test,," }), + ["spam.example", "junk.test"], + ); +}); + +test("blocks addresses on a listed domain regardless of case", () => { + const blocked = ["spam.example"]; + assert.equal(isSignupEmailBlocked("bot@spam.example", blocked), true); + assert.equal(isSignupEmailBlocked("Bot@SPAM.Example", blocked), true); + assert.equal(isSignupEmailBlocked(" bot@spam.example ", blocked), true); +}); + +test("blocks subdomains of a listed domain", () => { + assert.equal(isSignupEmailBlocked("bot@mail.spam.example", ["spam.example"]), true); +}); + +test("does not block lookalike or unrelated domains", () => { + const blocked = ["spam.example"]; + assert.equal(isSignupEmailBlocked("person@notspam.example", blocked), false); + assert.equal(isSignupEmailBlocked("person@spam.example.org", blocked), false); + assert.equal(isSignupEmailBlocked("spam.example@legit.test", blocked), false); +}); + +test("uses the domain after the last @", () => { + assert.equal(isSignupEmailBlocked('"a@legit.test"@spam.example', ["spam.example"]), true); + assert.equal(isSignupEmailBlocked("no-at-sign", ["spam.example"]), false); +}); diff --git a/apps/api/src/signup-email-domains.ts b/apps/api/src/signup-email-domains.ts new file mode 100644 index 00000000..8ec84c4a --- /dev/null +++ b/apps/api/src/signup-email-domains.ts @@ -0,0 +1,22 @@ +type SignupEmailDomainEnv = { + SIGNUP_BLOCKED_EMAIL_DOMAINS?: string; +}; + +// Comma-separated list of email domains that may not create accounts (e.g. +// a domain sending spam sign-ups). Subdomains of a listed domain are blocked +// too. Unset = nothing blocked. +export function blockedSignupEmailDomains(env: SignupEmailDomainEnv = process.env): string[] { + return (env.SIGNUP_BLOCKED_EMAIL_DOMAINS ?? "") + .split(",") + .map((value) => value.trim().replace(/^@/, "").toLowerCase()) + .filter((value) => value.length > 0); +} + +export function isSignupEmailBlocked(email: string, blockedDomains: readonly string[]): boolean { + if (blockedDomains.length === 0) return false; + const normalized = email.trim().toLowerCase(); + const at = normalized.lastIndexOf("@"); + if (at === -1) return false; + const domain = normalized.slice(at + 1); + return blockedDomains.some((blocked) => domain === blocked || domain.endsWith(`.${blocked}`)); +}