diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx
index a6ba5c9d54..d798faa6c7 100644
--- a/docs/developer-guide/environment-variables.mdx
+++ b/docs/developer-guide/environment-variables.mdx
@@ -40,6 +40,7 @@ The former build-time variables map to the new runtime variables as follows:
`UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read.
+`UI_SELF_REGISTRATION_ENABLED` is a runtime opt-out flag that Prowler Local Server reads only when `UI_CLOUD_ENABLED` is `"true"`. It defaults to on and turns off when set to `"false"`, matched case-insensitively so the same value can be shared with a backend setting written `False`. When it is off, the sign-up page only opens with an invitation token, the sign-in page drops its "Sign up" link, and the profile hides "Create organization"; invited users can still complete their registration. Outside a Prowler Cloud deployment the flag is ignored and account creation stays open.
## Registry UI Rollout and Rollback
`UI_REGISTRY_ENABLED` is an optional runtime flag for Prowler Cloud and Private Cloud. Registry is eligible only when both `UI_CLOUD_ENABLED` and `UI_REGISTRY_ENABLED` are the exact string `"true"` and the current user has the backend-authorized `manage_registry` permission. Unset, `"false"`, or malformed values fail closed. The flag defaults to off and is not a replacement for backend authorization. Registry access is independent of billing; Private Cloud can use it with `CLOUD_BILLING_ENABLED=false`.
diff --git a/ui/Dockerfile b/ui/Dockerfile
index 278c8b8602..22149a9fa9 100644
--- a/ui/Dockerfile
+++ b/ui/Dockerfile
@@ -100,6 +100,7 @@ ENV HOSTNAME="0.0.0.0"
# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot)
# - optional: UI_API_DOCS_URL
# - optional: UI_CLOUD_ENABLED ("true" only in Prowler Cloud deployments)
+# - optional: UI_SELF_REGISTRATION_ENABLED (Prowler Cloud only; "false" hides sign-up, invited users can still register)
# - optional: UI_REGISTRY_ENABLED ("true" only after the Registry dependency,
# Cloud role grant, and controlled acceptance are ready; unset/false hides Registry)
# - gated integrations (load only when *_ENABLED="true"; the value is then
diff --git a/ui/app/(auth)/(guest-only)/sign-in/page.tsx b/ui/app/(auth)/(guest-only)/sign-in/page.tsx
index c36226e61f..1ee3c37989 100644
--- a/ui/app/(auth)/(guest-only)/sign-in/page.tsx
+++ b/ui/app/(auth)/(guest-only)/sign-in/page.tsx
@@ -4,6 +4,7 @@ import {
isGithubOAuthEnabled,
isGoogleOAuthEnabled,
} from "@/lib/helper";
+import { isSelfRegistrationEnabled } from "@/lib/shared/env";
const SignIn = () => {
const GOOGLE_AUTH_URL = getAuthUrl("google");
@@ -15,6 +16,7 @@ const SignIn = () => {
githubAuthUrl={GITHUB_AUTH_URL}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
isGithubOAuthEnabled={isGithubOAuthEnabled}
+ isSelfRegistrationEnabled={isSelfRegistrationEnabled()}
/>
);
};
diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.test.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.test.tsx
new file mode 100644
index 0000000000..d1abc10660
--- /dev/null
+++ b/ui/app/(auth)/(guest-only)/sign-up/page.test.tsx
@@ -0,0 +1,86 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import SignUp from "./page";
+
+const { redirectMock, isSelfRegistrationEnabledMock } = vi.hoisted(() => ({
+ redirectMock: vi.fn(),
+ isSelfRegistrationEnabledMock: vi.fn(),
+}));
+
+vi.mock("next/navigation", () => ({
+ redirect: redirectMock,
+}));
+
+vi.mock("@/lib/shared/env", () => ({
+ isCloud: () => false,
+ isSelfRegistrationEnabled: isSelfRegistrationEnabledMock,
+}));
+
+vi.mock("@/lib/helper", () => ({
+ getAuthUrl: () => "",
+ isGithubOAuthEnabled: false,
+ isGoogleOAuthEnabled: false,
+}));
+
+vi.mock("@/components/auth/oss", () => ({
+ AuthForm: ({ invitationToken }: { invitationToken?: string | null }) => (
+
+ ),
+}));
+
+const renderPage = (searchParams: Record = {}) =>
+ SignUp({ searchParams: Promise.resolve(searchParams) });
+
+describe("SignUp page", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // next/navigation's redirect() never returns; mirror that so the page
+ // stops rendering the way it does in Next.
+ redirectMock.mockImplementation((url: string) => {
+ throw new Error(`NEXT_REDIRECT:${url}`);
+ });
+ });
+
+ describe("when self-registration is enabled", () => {
+ it("should render the sign-up form", async () => {
+ // Given
+ isSelfRegistrationEnabledMock.mockReturnValue(true);
+
+ // When
+ render(await renderPage());
+
+ // Then
+ expect(screen.getByTestId("auth-form")).toBeInTheDocument();
+ expect(redirectMock).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("when self-registration is disabled", () => {
+ it("should redirect to sign-in without an invitation", async () => {
+ // Given
+ isSelfRegistrationEnabledMock.mockReturnValue(false);
+
+ // When / Then
+ await expect(renderPage()).rejects.toThrow("NEXT_REDIRECT:/sign-in");
+ });
+
+ it("should still render the form for an invited user", async () => {
+ // Given
+ isSelfRegistrationEnabledMock.mockReturnValue(false);
+
+ // When
+ render(await renderPage({ invitation_token: "TESTING1234567" }));
+
+ // Then
+ expect(screen.getByTestId("auth-form")).toHaveAttribute(
+ "data-invitation-token",
+ "TESTING1234567",
+ );
+ expect(redirectMock).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.tsx
index 0415c6b0f3..884eedfa70 100644
--- a/ui/app/(auth)/(guest-only)/sign-up/page.tsx
+++ b/ui/app/(auth)/(guest-only)/sign-up/page.tsx
@@ -1,10 +1,12 @@
+import { redirect } from "next/navigation";
+
import { AuthForm } from "@/components/auth/oss";
import {
getAuthUrl,
isGithubOAuthEnabled,
isGoogleOAuthEnabled,
} from "@/lib/helper";
-import { isCloud } from "@/lib/shared/env";
+import { isCloud, isSelfRegistrationEnabled } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
const SignUp = async ({
@@ -17,6 +19,9 @@ const SignUp = async ({
typeof resolvedSearchParams?.invitation_token === "string"
? resolvedSearchParams.invitation_token
: null;
+ if (!invitationToken && !isSelfRegistrationEnabled()) {
+ redirect("/sign-in");
+ }
const isCloudEnv = isCloud();
const GOOGLE_AUTH_URL = getAuthUrl("google");
diff --git a/ui/app/api/auth/callback/github/route.test.ts b/ui/app/api/auth/callback/github/route.test.ts
new file mode 100644
index 0000000000..5e454e778e
--- /dev/null
+++ b/ui/app/api/auth/callback/github/route.test.ts
@@ -0,0 +1,68 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { fetchMock, signInMock } = vi.hoisted(() => ({
+ fetchMock: vi.fn(),
+ signInMock: vi.fn(),
+}));
+
+vi.mock("@/auth.config", () => ({
+ signIn: signInMock,
+}));
+
+vi.mock("@/lib/helper", () => ({
+ apiBaseUrl: "https://api.example.com/api/v1",
+ baseUrl: "https://app.example.com",
+}));
+
+import { GET } from "./route";
+
+describe("GitHub OAuth callback route", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal("fetch", fetchMock);
+ signInMock.mockResolvedValue({});
+ });
+
+ it("redirects to sign-in with a specific error when self-registration is disabled", async () => {
+ // Given
+ fetchMock.mockResolvedValue(
+ Response.json(
+ { errors: [{ code: "self_registration_disabled", status: "403" }] },
+ { status: 403 },
+ ),
+ );
+ const request = new Request(
+ "https://app.example.com/api/auth/callback/github?code=oauth-code",
+ );
+
+ // When
+ const response = await GET(request);
+
+ // Then
+ expect(fetchMock.mock.calls[0][0]).toBe(
+ "https://api.example.com/api/v1/tokens/github",
+ );
+ expect(response.headers.get("location")).toBe(
+ "https://app.example.com/sign-in?error=SelfRegistrationDisabled",
+ );
+ expect(signInMock).not.toHaveBeenCalled();
+ });
+
+ it("keeps the generic failure for other token exchange errors", async () => {
+ // Given
+ fetchMock.mockResolvedValue(
+ Response.json({ errors: [{ status: "400" }] }, { status: 400 }),
+ );
+ const request = new Request(
+ "https://app.example.com/api/auth/callback/github?code=oauth-code",
+ );
+
+ // When
+ const response = await GET(request);
+
+ // Then
+ expect(response.headers.get("location")).toBe(
+ "https://app.example.com/sign-in?error=AuthenticationFailed",
+ );
+ });
+});
diff --git a/ui/app/api/auth/callback/github/route.ts b/ui/app/api/auth/callback/github/route.ts
index a152206125..76f9ba790f 100644
--- a/ui/app/api/auth/callback/github/route.ts
+++ b/ui/app/api/auth/callback/github/route.ts
@@ -7,6 +7,7 @@ import {
getAttributionParamsFromCallbackPath,
getInvitationTokenFromCallbackPath,
getSafeCallbackPath,
+ isSelfRegistrationDisabledResponse,
} from "@/lib/auth-callback-url";
import { apiBaseUrl, baseUrl } from "@/lib/helper";
@@ -44,6 +45,11 @@ export async function GET(req: Request) {
});
if (!response.ok) {
+ if (await isSelfRegistrationDisabledResponse(response)) {
+ return NextResponse.redirect(
+ new URL("/sign-in?error=SelfRegistrationDisabled", baseUrl),
+ );
+ }
throw new Error("Failed to exchange code for tokens");
}
diff --git a/ui/app/api/auth/callback/google/route.test.ts b/ui/app/api/auth/callback/google/route.test.ts
index 96599d345a..bb1b8de3f4 100644
--- a/ui/app/api/auth/callback/google/route.test.ts
+++ b/ui/app/api/auth/callback/google/route.test.ts
@@ -51,4 +51,44 @@ describe("Google OAuth callback route", () => {
expect(body.get("promo_code")).toBe("black-hat-2026");
expect(body.get("utm_source")).toBe("blackhat");
});
+
+ it("redirects to sign-in with a specific error when self-registration is disabled", async () => {
+ // Given
+ fetchMock.mockResolvedValue(
+ Response.json(
+ { errors: [{ code: "self_registration_disabled", status: "403" }] },
+ { status: 403 },
+ ),
+ );
+ const request = new Request(
+ "https://app.example.com/api/auth/callback/google?code=oauth-code",
+ );
+
+ // When
+ const response = await GET(request);
+
+ // Then
+ expect(response.headers.get("location")).toBe(
+ "https://app.example.com/sign-in?error=SelfRegistrationDisabled",
+ );
+ expect(signInMock).not.toHaveBeenCalled();
+ });
+
+ it("keeps the generic failure for other token exchange errors", async () => {
+ // Given
+ fetchMock.mockResolvedValue(
+ Response.json({ errors: [{ status: "400" }] }, { status: 400 }),
+ );
+ const request = new Request(
+ "https://app.example.com/api/auth/callback/google?code=oauth-code",
+ );
+
+ // When
+ const response = await GET(request);
+
+ // Then
+ expect(response.headers.get("location")).toBe(
+ "https://app.example.com/sign-in?error=AuthenticationFailed",
+ );
+ });
});
diff --git a/ui/app/api/auth/callback/google/route.ts b/ui/app/api/auth/callback/google/route.ts
index fc8e263a94..fdf81c57bb 100644
--- a/ui/app/api/auth/callback/google/route.ts
+++ b/ui/app/api/auth/callback/google/route.ts
@@ -7,6 +7,7 @@ import {
getAttributionParamsFromCallbackPath,
getInvitationTokenFromCallbackPath,
getSafeCallbackPath,
+ isSelfRegistrationDisabledResponse,
} from "@/lib/auth-callback-url";
import { apiBaseUrl, baseUrl } from "@/lib/helper";
@@ -44,6 +45,11 @@ export async function GET(req: Request) {
});
if (!response.ok) {
+ if (await isSelfRegistrationDisabledResponse(response)) {
+ return NextResponse.redirect(
+ new URL("/sign-in?error=SelfRegistrationDisabled", baseUrl),
+ );
+ }
throw new Error("Failed to exchange code for tokens");
}
diff --git a/ui/changelog.d/disable-self-registration.added.md b/ui/changelog.d/disable-self-registration.added.md
new file mode 100644
index 0000000000..46e62eb1b3
--- /dev/null
+++ b/ui/changelog.d/disable-self-registration.added.md
@@ -0,0 +1 @@
+`UI_SELF_REGISTRATION_ENABLED` flag for Prowler Private Cloud deployments; when `"false"`, `/sign-up` only opens with an invitation, the sign-in page drops the "Sign up" link and the profile hides **Create organization**
diff --git a/ui/components/auth/oss/auth-form.tsx b/ui/components/auth/oss/auth-form.tsx
index a9f3e003d3..953bc1235b 100644
--- a/ui/components/auth/oss/auth-form.tsx
+++ b/ui/components/auth/oss/auth-form.tsx
@@ -9,6 +9,7 @@ export const AuthForm = ({
githubAuthUrl,
isGoogleOAuthEnabled,
isGithubOAuthEnabled,
+ isSelfRegistrationEnabled = true,
}: {
type: string;
invitationToken?: string | null;
@@ -17,6 +18,7 @@ export const AuthForm = ({
githubAuthUrl?: string;
isGoogleOAuthEnabled?: boolean;
isGithubOAuthEnabled?: boolean;
+ isSelfRegistrationEnabled?: boolean;
}) => {
if (type === "sign-in") {
return (
@@ -25,6 +27,7 @@ export const AuthForm = ({
githubAuthUrl={githubAuthUrl}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
isGithubOAuthEnabled={isGithubOAuthEnabled}
+ isSelfRegistrationEnabled={isSelfRegistrationEnabled}
/>
);
}
diff --git a/ui/components/auth/oss/sign-in-form.tsx b/ui/components/auth/oss/sign-in-form.tsx
index 43ad982217..ed51346738 100644
--- a/ui/components/auth/oss/sign-in-form.tsx
+++ b/ui/components/auth/oss/sign-in-form.tsx
@@ -34,11 +34,13 @@ export const SignInForm = ({
githubAuthUrl,
isGoogleOAuthEnabled,
isGithubOAuthEnabled,
+ isSelfRegistrationEnabled = true,
}: {
googleAuthUrl?: string;
githubAuthUrl?: string;
isGoogleOAuthEnabled?: boolean;
isGithubOAuthEnabled?: boolean;
+ isSelfRegistrationEnabled?: boolean;
}) => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -80,6 +82,11 @@ export const SignInForm = ({
description:
"There was a problem with your session. Please sign in again.",
},
+ SelfRegistrationDisabled: {
+ title: "Registration Disabled",
+ description:
+ "Self-registration is disabled. Ask an administrator for an invitation.",
+ },
};
const errorConfig = errorMessages[sessionError] || {
@@ -161,11 +168,13 @@ export const SignInForm = ({
+ isSelfRegistrationEnabled ? (
+
+ ) : undefined
}
>