` `unoptimized` decision can never drift.
+ *
+ * Two hard constraints shape this list:
+ * - `next.config` (and therefore `remotePatterns`) is frozen into the build. The same Docker image
+ * serves multiple domains (app.formbricks.com, ksa.formbricks.com, and every self-hoster), so the
+ * deployment's own domain can NOT be baked in here and is intentionally absent.
+ * - First-party uploads are served from same-origin `/storage/...` (relative) paths, which Next
+ * treats as local images (default: optimize all) and never checks against `remotePatterns`.
+ *
+ * It therefore contains only *universal* provider hosts that real features rely on and that are
+ * identical on every deployment.
+ */
+export const OPTIMIZABLE_IMAGE_HOSTS = [
+ // OAuth profile avatars
+ "avatars.githubusercontent.com",
+ "avatars.slack-edge.com",
+ "lh3.googleusercontent.com",
+ // survey editor's Unsplash background picker
+ "images.unsplash.com",
+ // local development
+ "localhost",
+ "127.0.0.1",
+];
diff --git a/apps/web/lib/turbo-build-outputs.test.ts b/apps/web/lib/turbo-build-outputs.test.ts
index 91478279805c..57a043448118 100644
--- a/apps/web/lib/turbo-build-outputs.test.ts
+++ b/apps/web/lib/turbo-build-outputs.test.ts
@@ -21,7 +21,8 @@ describe("turbo.json web build excludes transient Next.js dirs", () => {
};
// Turbo uses the package-specific task when defined, otherwise the generic one.
- const resolvedOutputs = turboJson.tasks["@formbricks/web#build"]?.outputs ?? turboJson.tasks.build.outputs ?? [];
+ const resolvedOutputs =
+ turboJson.tasks["@formbricks/web#build"]?.outputs ?? turboJson.tasks.build.outputs ?? [];
test("resolved @formbricks/web#build outputs exclude .next/cache and .next/dev", () => {
const missing = REQUIRED_EXCLUSIONS.filter((exclusion) => !resolvedOutputs.includes(exclusion));
diff --git a/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx b/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx
index 18c77e3d9ea9..4a8521e6d430 100644
--- a/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx
+++ b/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx
@@ -11,6 +11,7 @@ import { TAllowedFileExtension } from "@formbricks/types/storage";
import { TUser } from "@formbricks/types/user";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
+import { isExternalImageSrc } from "@/lib/image-hosts";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
removeOrganizationEmailLogoUrlAction,
@@ -219,6 +220,7 @@ export const EmailCustomizationSettings = ({
className="max-h-24 max-w-full object-contain"
width={192}
height={192}
+ unoptimized={isExternalImageSrc(logoUrl)}
/>
@@ -287,6 +289,7 @@ export const EmailCustomizationSettings = ({
className="mx-auto max-h-[100px] max-w-full object-contain"
width={192}
height={192}
+ unoptimized={isExternalImageSrc(logoUrl || fbLogoUrl)}
/>
{t("workspace.settings.general.email_customization_preview_email_heading", {
diff --git a/apps/web/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings.tsx b/apps/web/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings.tsx
index a1b966fcd555..e0c7faed71fc 100644
--- a/apps/web/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings.tsx
+++ b/apps/web/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings.tsx
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
+import { isExternalImageSrc } from "@/lib/image-hosts";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
removeOrganizationFaviconUrlAction,
@@ -172,6 +173,7 @@ export const FaviconCustomizationSettings = ({
width={64}
height={64}
className="-mb-2 size-16 rounded-lg border object-contain p-1"
+ unoptimized={isExternalImageSrc(faviconUrl)}
/>
) : (
diff --git a/apps/web/modules/survey/editor/components/option-ids.tsx b/apps/web/modules/survey/editor/components/option-ids.tsx
index c1d7fc8c3578..2d9cea2d2d26 100644
--- a/apps/web/modules/survey/editor/components/option-ids.tsx
+++ b/apps/web/modules/survey/editor/components/option-ids.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveyVariable } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
+import { isExternalImageSrc } from "@/lib/image-hosts";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
@@ -54,6 +55,7 @@ export const OptionIds = (props: OptionIdsProps) => {
style={{ objectFit: "cover" }}
quality={75}
className="rounded-lg transition-opacity duration-200"
+ unoptimized={isExternalImageSrc(imageUrl)}
/>
diff --git a/apps/web/modules/survey/editor/components/unsplash-images.tsx b/apps/web/modules/survey/editor/components/unsplash-images.tsx
index d1559ae9cdf0..0c8773c163de 100644
--- a/apps/web/modules/survey/editor/components/unsplash-images.tsx
+++ b/apps/web/modules/survey/editor/components/unsplash-images.tsx
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurveyBackgroundBgType } from "@formbricks/types/surveys/types";
+import { isExternalImageSrc } from "@/lib/image-hosts";
import { debounce } from "@/lib/utils/debounce";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
@@ -213,6 +214,7 @@ export const ImageFromUnsplashSurveyBg = ({ handleBgChange }: ImageFromUnsplashS
alt={image.alt_description}
onClick={() => handleImageSelected(image.urls.regularWithAttribution, image.urls.download)}
className="h-full cursor-pointer rounded-lg object-cover"
+ unoptimized={isExternalImageSrc(image.urls.regularWithAttribution)}
/>
{image.authorName && (
diff --git a/apps/web/modules/survey/link/actions.ts b/apps/web/modules/survey/link/actions.ts
index a2e416dd2176..ac685732e074 100644
--- a/apps/web/modules/survey/link/actions.ts
+++ b/apps/web/modules/survey/link/actions.ts
@@ -10,6 +10,7 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendLinkSurveyToVerifiedEmail } from "@/modules/email";
import { getSurveyWithMetadata, isSurveyResponsePresent } from "@/modules/survey/link/lib/data";
+import { createLinkSurveyPinToken } from "@/modules/survey/link/lib/pin-token";
export const sendLinkSurveyEmailAction = actionClient
.inputSchema(ZLinkSurveyEmailData)
@@ -53,7 +54,7 @@ export const validateSurveyPinAction = actionClient
throw new InvalidInputError("INVALID_PIN");
}
- return { survey };
+ return { survey, pinAuthToken: createLinkSurveyPinToken(survey.id) };
});
const ZIsSurveyResponsePresentAction = z.object({
diff --git a/apps/web/modules/survey/link/components/pin-screen.tsx b/apps/web/modules/survey/link/components/pin-screen.tsx
index 961c85d8dc64..023d84e9b519 100644
--- a/apps/web/modules/survey/link/components/pin-screen.tsx
+++ b/apps/web/modules/survey/link/components/pin-screen.tsx
@@ -62,6 +62,7 @@ export const PinScreen = (props: PinScreenProps) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [survey, setSurvey] = useState();
+ const [pinAuthToken, setPinAuthToken] = useState();
const isCardless = styling.cardArrangement?.linkSurveys === "cardless";
const linkSurveyCardMaxWidth = getLinkSurveyCardMaxWidth(styling.linkSurveyCardWidth);
@@ -91,6 +92,7 @@ export const PinScreen = (props: PinScreenProps) => {
const response = await validateSurveyPinAction({ surveyId, pin: localPinEntry });
if (response?.data?.survey) {
setSurvey(response.data.survey);
+ setPinAuthToken(response.data.pinAuthToken ?? undefined);
} else {
const errorMessage = getFormattedErrorMessage(response);
setError(errorMessage);
@@ -149,6 +151,7 @@ export const PinScreen = (props: PinScreenProps) => {
PRIVACY_URL={PRIVACY_URL}
TERMS_URL={TERMS_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
+ pinAuthToken={pinAuthToken}
/>
);
};
diff --git a/apps/web/modules/survey/link/components/survey-client-wrapper.tsx b/apps/web/modules/survey/link/components/survey-client-wrapper.tsx
index e6c6304ecd87..49cddea0628d 100644
--- a/apps/web/modules/survey/link/components/survey-client-wrapper.tsx
+++ b/apps/web/modules/survey/link/components/survey-client-wrapper.tsx
@@ -37,6 +37,7 @@ interface SurveyClientWrapperProps {
PRIVACY_URL?: string;
TERMS_URL?: string;
IS_FORMBRICKS_CLOUD: boolean;
+ pinAuthToken?: string;
}
let setBlockId = (_: string) => {};
@@ -62,6 +63,7 @@ export const SurveyClientWrapper = ({
PRIVACY_URL,
TERMS_URL,
IS_FORMBRICKS_CLOUD,
+ pinAuthToken,
}: SurveyClientWrapperProps) => {
const searchParams = useSearchParams();
const { i18n } = useTranslation();
@@ -244,6 +246,7 @@ export const SurveyClientWrapper = ({
}}
singleUseId={singleUseId}
singleUseResponseId={singleUseResponseId}
+ pinAuthToken={pinAuthToken}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
contactId={contactId}
userId={userId}
diff --git a/apps/web/modules/survey/link/lib/pin-token.test.ts b/apps/web/modules/survey/link/lib/pin-token.test.ts
new file mode 100644
index 000000000000..217747ac75ad
--- /dev/null
+++ b/apps/web/modules/survey/link/lib/pin-token.test.ts
@@ -0,0 +1,102 @@
+import jwt from "jsonwebtoken";
+import { describe, expect, test, vi } from "vitest";
+
+vi.mock("server-only", () => ({}));
+
+vi.mock("@formbricks/logger", () => ({
+ logger: { warn: vi.fn() },
+}));
+
+const TEST_SECRET = "test-secret-at-least-32-chars-long!!";
+
+vi.mock("@/lib/constants", () => ({
+ BETTER_AUTH_SECRET: undefined,
+ NEXTAUTH_SECRET: TEST_SECRET,
+}));
+
+// Import after mocks are set up
+const { createLinkSurveyPinToken, verifyLinkSurveyPinToken } = await import("./pin-token");
+
+const SURVEY_ID = "clxsurvey0000abcdef123456";
+
+describe("createLinkSurveyPinToken", () => {
+ test("returns a signed JWT string", () => {
+ const token = createLinkSurveyPinToken(SURVEY_ID);
+ expect(typeof token).toBe("string");
+ expect(token.split(".")).toHaveLength(3);
+ });
+});
+
+describe("verifyLinkSurveyPinToken", () => {
+ test("valid token for correct surveyId returns true", () => {
+ const token = createLinkSurveyPinToken(SURVEY_ID);
+ expect(verifyLinkSurveyPinToken(token, SURVEY_ID)).toBe(true);
+ });
+
+ test("valid token with wrong surveyId returns false", () => {
+ const token = createLinkSurveyPinToken(SURVEY_ID);
+ expect(verifyLinkSurveyPinToken(token, "clxsurveyXXXXabcdef000000")).toBe(false);
+ });
+
+ test("tampered token returns false", () => {
+ const token = createLinkSurveyPinToken(SURVEY_ID);
+ const parts = token.split(".");
+ // Flip a character in the signature
+ const tampered = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -1)}X`;
+ expect(verifyLinkSurveyPinToken(tampered, SURVEY_ID)).toBe(false);
+ });
+
+ test("null token returns false", () => {
+ expect(verifyLinkSurveyPinToken(null, SURVEY_ID)).toBe(false);
+ });
+
+ test("undefined token returns false", () => {
+ expect(verifyLinkSurveyPinToken(undefined, SURVEY_ID)).toBe(false);
+ });
+
+ test("token signed with a different purpose returns false", () => {
+ const wrongPurposeToken = jwt.sign({ surveyId: SURVEY_ID, purpose: "some_other_purpose" }, TEST_SECRET, {
+ algorithm: "HS256",
+ });
+ expect(verifyLinkSurveyPinToken(wrongPurposeToken, SURVEY_ID)).toBe(false);
+ });
+
+ test("expired token returns false", () => {
+ const expiredToken = jwt.sign({ surveyId: SURVEY_ID, purpose: "link_survey_pin" }, TEST_SECRET, {
+ algorithm: "HS256",
+ expiresIn: -1,
+ });
+ expect(verifyLinkSurveyPinToken(expiredToken, SURVEY_ID)).toBe(false);
+ });
+});
+
+describe("secret resolution", () => {
+ test("prefers BETTER_AUTH_SECRET over NEXTAUTH_SECRET", async () => {
+ vi.resetModules();
+ const betterAuthSecret = "better-auth-secret-at-least-32-chars!!";
+ vi.doMock("@/lib/constants", () => ({
+ BETTER_AUTH_SECRET: betterAuthSecret,
+ NEXTAUTH_SECRET: TEST_SECRET,
+ }));
+ const mod = await import("./pin-token");
+
+ const token = mod.createLinkSurveyPinToken(SURVEY_ID);
+ // Verifies against BETTER_AUTH_SECRET, not the NextAuth fallback.
+ expect(() => jwt.verify(token, betterAuthSecret)).not.toThrow();
+ expect(() => jwt.verify(token, TEST_SECRET)).toThrow();
+ expect(mod.verifyLinkSurveyPinToken(token, SURVEY_ID)).toBe(true);
+ });
+
+ test("falls back to NEXTAUTH_SECRET when BETTER_AUTH_SECRET is unset", async () => {
+ vi.resetModules();
+ vi.doMock("@/lib/constants", () => ({
+ BETTER_AUTH_SECRET: undefined,
+ NEXTAUTH_SECRET: TEST_SECRET,
+ }));
+ const mod = await import("./pin-token");
+
+ const token = mod.createLinkSurveyPinToken(SURVEY_ID);
+ expect(() => jwt.verify(token, TEST_SECRET)).not.toThrow();
+ expect(mod.verifyLinkSurveyPinToken(token, SURVEY_ID)).toBe(true);
+ });
+});
diff --git a/apps/web/modules/survey/link/lib/pin-token.ts b/apps/web/modules/survey/link/lib/pin-token.ts
new file mode 100644
index 000000000000..de514c5d5bff
--- /dev/null
+++ b/apps/web/modules/survey/link/lib/pin-token.ts
@@ -0,0 +1,46 @@
+import "server-only";
+import jwt from "jsonwebtoken";
+import { logger } from "@formbricks/logger";
+import { BETTER_AUTH_SECRET, NEXTAUTH_SECRET } from "@/lib/constants";
+
+const PIN_TOKEN_PURPOSE = "link_survey_pin";
+const PIN_TOKEN_TTL_SECONDS = 60 * 60; // 1 hour
+
+// Mirror the auth session secret resolution (auth.ts / session-cookie.ts): prefer
+// BETTER_AUTH_SECRET, fall back to NEXTAUTH_SECRET. A better-auth-only deployment sets only
+// BETTER_AUTH_SECRET, so keying PIN tokens off NEXTAUTH_SECRET alone would throw and break PIN
+// enforcement even though auth itself works.
+//
+// Resolved lazily inside the functions rather than at module scope: reading the constants at
+// import time makes every unrelated test that mocks "@/lib/constants" (and transitively imports
+// this module) fail Vitest's strict missing-export check. Deferring the access keeps import
+// side-effect-free, matching the pre-existing pattern.
+const resolvePinTokenSecret = (): string | undefined => BETTER_AUTH_SECRET ?? NEXTAUTH_SECRET;
+
+export const createLinkSurveyPinToken = (surveyId: string): string => {
+ const secret = resolvePinTokenSecret();
+ if (!secret) {
+ throw new Error("No auth secret set (BETTER_AUTH_SECRET or NEXTAUTH_SECRET)");
+ }
+ return jwt.sign({ surveyId, purpose: PIN_TOKEN_PURPOSE }, secret, {
+ algorithm: "HS256",
+ expiresIn: PIN_TOKEN_TTL_SECONDS,
+ });
+};
+
+export const verifyLinkSurveyPinToken = (token: string | null | undefined, surveyId: string): boolean => {
+ const secret = resolvePinTokenSecret();
+ if (!token || !secret) {
+ return false;
+ }
+ try {
+ const payload = jwt.verify(token, secret, { algorithms: ["HS256"] }) as jwt.JwtPayload & {
+ surveyId?: string;
+ purpose?: string;
+ };
+ return payload.purpose === PIN_TOKEN_PURPOSE && payload.surveyId === surveyId;
+ } catch (error) {
+ logger.warn({ error, surveyId }, "Invalid link survey PIN token");
+ return false;
+ }
+};
diff --git a/apps/web/modules/ui/components/client-logo/index.tsx b/apps/web/modules/ui/components/client-logo/index.tsx
index 54740e541b12..a05c0c280520 100644
--- a/apps/web/modules/ui/components/client-logo/index.tsx
+++ b/apps/web/modules/ui/components/client-logo/index.tsx
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { Workspace } from "@formbricks/database/prisma-browser";
import { TLogo } from "@formbricks/types/styling";
import { cn } from "@/lib/cn";
+import { isExternalImageSrc } from "@/lib/image-hosts";
interface ClientLogoProps {
workspaceLogo: Workspace["logo"] | null;
@@ -73,6 +74,7 @@ export const ClientLogo = ({
width={256}
height={64}
alt={t("workspace.surveys.edit.company_logo")}
+ unoptimized={isExternalImageSrc(logoToUse?.url)}
/>
) : disableLinks ? (
diff --git a/apps/web/modules/ui/components/connect-integration/index.tsx b/apps/web/modules/ui/components/connect-integration/index.tsx
index 92ac5aed3e40..8cefedcd604c 100644
--- a/apps/web/modules/ui/components/connect-integration/index.tsx
+++ b/apps/web/modules/ui/components/connect-integration/index.tsx
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { TIntegrationType } from "@formbricks/types/integration";
+import { isExternalImageSrc } from "@/lib/image-hosts";
import { Button } from "@/modules/ui/components/button";
import { FormbricksLogo } from "@/modules/ui/components/formbricks-logo";
import { getIntegrationDetails } from "./lib/utils";
@@ -54,7 +55,12 @@ export const ConnectIntegration = ({
-
+
{integrationDetails?.text}
diff --git a/apps/web/modules/ui/components/file-input/index.tsx b/apps/web/modules/ui/components/file-input/index.tsx
index 90d96411a6c0..35de40efe134 100644
--- a/apps/web/modules/ui/components/file-input/index.tsx
+++ b/apps/web/modules/ui/components/file-input/index.tsx
@@ -7,6 +7,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { cn } from "@/lib/cn";
+import { isExternalImageSrc } from "@/lib/image-hosts";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
@@ -261,11 +262,12 @@ export const FileInput = ({
style={{ objectFit: "cover" }}
quality={100}
className={!file.uploaded ? "opacity-50" : ""}
+ unoptimized={isExternalImageSrc(file.url)}
/>
{file.uploaded ? (