VYPR
Critical severity9.4OSV Advisory· Published Sep 26, 2025· Updated Apr 15, 2026

CVE-2025-59934

CVE-2025-59934

Description

Formbricks is an open source qualtrics alternative. Prior to version 4.0.1, Formbricks is missing JWT signature verification. This vulnerability stems from a token validation routine that only decodes JWTs (jwt.decode) without verifying their signatures. Both the email verification token login path and the password reset server action use the same validator, which does not check the token’s signature, expiration, issuer, or audience. If an attacker learns the victim’s actual user.id, they can craft an arbitrary JWT with an alg: "none" header and use it to authenticate and reset the victim’s password. This issue has been patched in version 4.0.1.

Affected products

1

Patches

2
d49517be91fa

fix(ci): backport release tag validation fix to release/4.0 (#6609)

https://github.com/formbricks/formbricksMatti NanntSep 26, 2025via osv
1 file changed · +3 3
  • .github/workflows/move-stable-tag.yml+3 3 modified
    @@ -4,7 +4,7 @@ on:
       workflow_call:
         inputs:
           release_tag:
    -        description: "The release tag name (e.g., v1.2.3)"
    +        description: "The release tag name (e.g., 1.2.3)"
             required: true
             type: string
           commit_sha:
    @@ -53,8 +53,8 @@ jobs:
               set -euo pipefail
     
               # Validate release tag format
    -          if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
    -            echo "❌ Error: Invalid release tag format. Expected format: v1.2.3, v1.2.3-alpha"
    +          if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
    +            echo "❌ Error: Invalid release tag format. Expected format: 1.2.3, 1.2.3-alpha"
                 echo "Provided: $RELEASE_TAG"
                 exit 1
               fi
    
eb1349f20518

fix: enhance JWT handling with improved encryption and decryption logic (#6596)

https://github.com/formbricks/formbricksVictor Hugo dos SantosSep 25, 2025via osv
4 files changed · +1096 135
  • apps/web/lib/jwt.test.ts+865 28 modified
    @@ -1,6 +1,7 @@
    -import { env } from "@/lib/env";
    +import jwt from "jsonwebtoken";
     import { beforeEach, describe, expect, test, vi } from "vitest";
     import { prisma } from "@formbricks/database";
    +import * as crypto from "@/lib/crypto";
     import {
       createEmailChangeToken,
       createEmailToken,
    @@ -14,12 +15,69 @@ import {
       verifyTokenForLinkSurvey,
     } from "./jwt";
     
    +const TEST_ENCRYPTION_KEY = "0".repeat(32); // 32-byte key for AES-256-GCM
    +const TEST_NEXTAUTH_SECRET = "test-nextauth-secret";
    +const DIFFERENT_SECRET = "different-secret";
    +
    +// Error message constants
    +const NEXTAUTH_SECRET_ERROR = "NEXTAUTH_SECRET is not set";
    +const ENCRYPTION_KEY_ERROR = "ENCRYPTION_KEY is not set";
    +
    +// Helper function to test error cases for missing secrets/keys
    +const testMissingSecretsError = async (
    +  testFn: (...args: any[]) => any,
    +  args: any[],
    +  options: {
    +    testNextAuthSecret?: boolean;
    +    testEncryptionKey?: boolean;
    +    isAsync?: boolean;
    +  } = {}
    +) => {
    +  const { testNextAuthSecret = true, testEncryptionKey = true, isAsync = false } = options;
    +
    +  if (testNextAuthSecret) {
    +    const constants = await import("@/lib/constants");
    +    const originalSecret = (constants as any).NEXTAUTH_SECRET;
    +    (constants as any).NEXTAUTH_SECRET = undefined;
    +
    +    if (isAsync) {
    +      await expect(testFn(...args)).rejects.toThrow(NEXTAUTH_SECRET_ERROR);
    +    } else {
    +      expect(() => testFn(...args)).toThrow(NEXTAUTH_SECRET_ERROR);
    +    }
    +
    +    // Restore
    +    (constants as any).NEXTAUTH_SECRET = originalSecret;
    +  }
    +
    +  if (testEncryptionKey) {
    +    const constants = await import("@/lib/constants");
    +    const originalKey = (constants as any).ENCRYPTION_KEY;
    +    (constants as any).ENCRYPTION_KEY = undefined;
    +
    +    if (isAsync) {
    +      await expect(testFn(...args)).rejects.toThrow(ENCRYPTION_KEY_ERROR);
    +    } else {
    +      expect(() => testFn(...args)).toThrow(ENCRYPTION_KEY_ERROR);
    +    }
    +
    +    // Restore
    +    (constants as any).ENCRYPTION_KEY = originalKey;
    +  }
    +};
    +
     // Mock environment variables
     vi.mock("@/lib/env", () => ({
       env: {
    -    ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
    +    ENCRYPTION_KEY: "0".repeat(32),
         NEXTAUTH_SECRET: "test-nextauth-secret",
    -  } as typeof env,
    +  },
    +}));
    +
    +// Mock constants
    +vi.mock("@/lib/constants", () => ({
    +  NEXTAUTH_SECRET: "test-nextauth-secret",
    +  ENCRYPTION_KEY: "0".repeat(32),
     }));
     
     // Mock prisma
    @@ -31,22 +89,65 @@ vi.mock("@formbricks/database", () => ({
       },
     }));
     
    -describe("JWT Functions", () => {
    +// Mock logger
    +vi.mock("@formbricks/logger", () => ({
    +  logger: {
    +    error: vi.fn(),
    +    warn: vi.fn(),
    +    info: vi.fn(),
    +  },
    +}));
    +
    +describe("JWT Functions - Comprehensive Security Tests", () => {
       const mockUser = {
         id: "test-user-id",
         email: "test@example.com",
       };
     
    +  let mockSymmetricEncrypt: any;
    +  let mockSymmetricDecrypt: any;
    +
       beforeEach(() => {
         vi.clearAllMocks();
    +
    +    // Setup default crypto mocks
    +    mockSymmetricEncrypt = vi
    +      .spyOn(crypto, "symmetricEncrypt")
    +      .mockImplementation((text: string) => `encrypted_${text}`);
    +
    +    mockSymmetricDecrypt = vi
    +      .spyOn(crypto, "symmetricDecrypt")
    +      .mockImplementation((encryptedText: string) => encryptedText.replace("encrypted_", ""));
    +
         (prisma.user.findUnique as any).mockResolvedValue(mockUser);
       });
     
       describe("createToken", () => {
    -    test("should create a valid token", () => {
    -      const token = createToken(mockUser.id, mockUser.email);
    +    test("should create a valid token with encrypted user ID", () => {
    +      const token = createToken(mockUser.id);
           expect(token).toBeDefined();
           expect(typeof token).toBe("string");
    +      expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
    +    });
    +
    +    test("should accept custom options", () => {
    +      const customOptions = { expiresIn: "1h" };
    +      const token = createToken(mockUser.id, customOptions);
    +      expect(token).toBeDefined();
    +
    +      // Verify the token contains the expected expiration
    +      const decoded = jwt.decode(token) as any;
    +      expect(decoded.exp).toBeDefined();
    +      expect(decoded.iat).toBeDefined();
    +      // Should expire in approximately 1 hour (3600 seconds)
    +      expect(decoded.exp - decoded.iat).toBe(3600);
    +    });
    +
    +    test("should throw error if NEXTAUTH_SECRET is not set", async () => {
    +      await testMissingSecretsError(createToken, [mockUser.id], {
    +        testNextAuthSecret: true,
    +        testEncryptionKey: false,
    +      });
         });
       });
     
    @@ -56,6 +157,18 @@ describe("JWT Functions", () => {
           const token = createTokenForLinkSurvey(surveyId, mockUser.email);
           expect(token).toBeDefined();
           expect(typeof token).toBe("string");
    +      expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
    +    });
    +
    +    test("should include surveyId in payload", () => {
    +      const surveyId = "test-survey-id";
    +      const token = createTokenForLinkSurvey(surveyId, mockUser.email);
    +      const decoded = jwt.decode(token) as any;
    +      expect(decoded.surveyId).toBe(surveyId);
    +    });
    +
    +    test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
    +      await testMissingSecretsError(createTokenForLinkSurvey, ["survey-id", mockUser.email]);
         });
       });
     
    @@ -64,24 +177,30 @@ describe("JWT Functions", () => {
           const token = createEmailToken(mockUser.email);
           expect(token).toBeDefined();
           expect(typeof token).toBe("string");
    +      expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
         });
     
    -    test("should throw error if NEXTAUTH_SECRET is not set", () => {
    -      const originalSecret = env.NEXTAUTH_SECRET;
    -      try {
    -        (env as any).NEXTAUTH_SECRET = undefined;
    -        expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set");
    -      } finally {
    -        (env as any).NEXTAUTH_SECRET = originalSecret;
    -      }
    +    test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
    +      await testMissingSecretsError(createEmailToken, [mockUser.email]);
         });
       });
     
    -  describe("getEmailFromEmailToken", () => {
    -    test("should extract email from valid token", () => {
    -      const token = createEmailToken(mockUser.email);
    -      const extractedEmail = getEmailFromEmailToken(token);
    -      expect(extractedEmail).toBe(mockUser.email);
    +  describe("createEmailChangeToken", () => {
    +    test("should create a valid email change token with 1 day expiration", () => {
    +      const token = createEmailChangeToken(mockUser.id, mockUser.email);
    +      expect(token).toBeDefined();
    +      expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
    +      expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
    +
    +      const decoded = jwt.decode(token) as any;
    +      expect(decoded.exp).toBeDefined();
    +      expect(decoded.iat).toBeDefined();
    +      // Should expire in approximately 1 day (86400 seconds)
    +      expect(decoded.exp - decoded.iat).toBe(86400);
    +    });
    +
    +    test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
    +      await testMissingSecretsError(createEmailChangeToken, [mockUser.id, mockUser.email]);
         });
       });
     
    @@ -91,6 +210,50 @@ describe("JWT Functions", () => {
           const token = createInviteToken(inviteId, mockUser.email);
           expect(token).toBeDefined();
           expect(typeof token).toBe("string");
    +      expect(mockSymmetricEncrypt).toHaveBeenCalledWith(inviteId, TEST_ENCRYPTION_KEY);
    +      expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
    +    });
    +
    +    test("should accept custom options", () => {
    +      const inviteId = "test-invite-id";
    +      const customOptions = { expiresIn: "24h" };
    +      const token = createInviteToken(inviteId, mockUser.email, customOptions);
    +      expect(token).toBeDefined();
    +
    +      const decoded = jwt.decode(token) as any;
    +      expect(decoded.exp).toBeDefined();
    +      expect(decoded.iat).toBeDefined();
    +      // Should expire in approximately 24 hours (86400 seconds)
    +      expect(decoded.exp - decoded.iat).toBe(86400);
    +    });
    +
    +    test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
    +      await testMissingSecretsError(createInviteToken, ["invite-id", mockUser.email]);
    +    });
    +  });
    +
    +  describe("getEmailFromEmailToken", () => {
    +    test("should extract email from valid token", () => {
    +      const token = createEmailToken(mockUser.email);
    +      const extractedEmail = getEmailFromEmailToken(token);
    +      expect(extractedEmail).toBe(mockUser.email);
    +      expect(mockSymmetricDecrypt).toHaveBeenCalledWith(`encrypted_${mockUser.email}`, TEST_ENCRYPTION_KEY);
    +    });
    +
    +    test("should fall back to original email if decryption fails", () => {
    +      mockSymmetricDecrypt.mockImplementationOnce(() => {
    +        throw new Error("Decryption failed");
    +      });
    +
    +      // Create token manually with unencrypted email for legacy compatibility
    +      const legacyToken = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
    +      const extractedEmail = getEmailFromEmailToken(legacyToken);
    +      expect(extractedEmail).toBe(mockUser.email);
    +    });
    +
    +    test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
    +      const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
    +      await testMissingSecretsError(getEmailFromEmailToken, [token]);
         });
       });
     
    @@ -106,23 +269,194 @@ describe("JWT Functions", () => {
           const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id");
           expect(result).toBeNull();
         });
    +
    +    test("should return null if NEXTAUTH_SECRET is not set", async () => {
    +      const constants = await import("@/lib/constants");
    +      const originalSecret = (constants as any).NEXTAUTH_SECRET;
    +      (constants as any).NEXTAUTH_SECRET = undefined;
    +
    +      const result = verifyTokenForLinkSurvey("any-token", "test-survey-id");
    +      expect(result).toBeNull();
    +
    +      // Restore
    +      (constants as any).NEXTAUTH_SECRET = originalSecret;
    +    });
    +
    +    test("should return null if surveyId doesn't match", () => {
    +      const surveyId = "test-survey-id";
    +      const differentSurveyId = "different-survey-id";
    +      const token = createTokenForLinkSurvey(surveyId, mockUser.email);
    +      const result = verifyTokenForLinkSurvey(token, differentSurveyId);
    +      expect(result).toBeNull();
    +    });
    +
    +    test("should return null if email is missing from payload", () => {
    +      const tokenWithoutEmail = jwt.sign({ surveyId: "test-survey-id" }, TEST_NEXTAUTH_SECRET);
    +      const result = verifyTokenForLinkSurvey(tokenWithoutEmail, "test-survey-id");
    +      expect(result).toBeNull();
    +    });
    +
    +    test("should fall back to original email if decryption fails", () => {
    +      mockSymmetricDecrypt.mockImplementationOnce(() => {
    +        throw new Error("Decryption failed");
    +      });
    +
    +      // Create legacy token with unencrypted email
    +      const legacyToken = jwt.sign(
    +        {
    +          email: mockUser.email,
    +          surveyId: "test-survey-id",
    +        },
    +        TEST_NEXTAUTH_SECRET
    +      );
    +
    +      const result = verifyTokenForLinkSurvey(legacyToken, "test-survey-id");
    +      expect(result).toBe(mockUser.email);
    +    });
    +
    +    test("should fall back to original email if ENCRYPTION_KEY is not set", async () => {
    +      const constants = await import("@/lib/constants");
    +      const originalKey = (constants as any).ENCRYPTION_KEY;
    +      (constants as any).ENCRYPTION_KEY = undefined;
    +
    +      // Create a token with unencrypted email (as it would be if ENCRYPTION_KEY was not set during creation)
    +      const token = jwt.sign(
    +        {
    +          email: mockUser.email,
    +          surveyId: "survey-id",
    +        },
    +        TEST_NEXTAUTH_SECRET
    +      );
    +
    +      const result = verifyTokenForLinkSurvey(token, "survey-id");
    +      expect(result).toBe(mockUser.email);
    +
    +      // Restore
    +      (constants as any).ENCRYPTION_KEY = originalKey;
    +    });
    +
    +    test("should verify legacy survey tokens with surveyId-based secret", async () => {
    +      const surveyId = "test-survey-id";
    +
    +      // Create legacy token with old format (NEXTAUTH_SECRET + surveyId)
    +      const legacyToken = jwt.sign({ email: `encrypted_${mockUser.email}` }, TEST_NEXTAUTH_SECRET + surveyId);
    +
    +      const result = verifyTokenForLinkSurvey(legacyToken, surveyId);
    +      expect(result).toBe(mockUser.email);
    +    });
    +
    +    test("should reject survey tokens that fail both new and legacy verification", async () => {
    +      const surveyId = "test-survey-id";
    +      const invalidToken = jwt.sign({ email: "encrypted_test@example.com" }, "wrong-secret");
    +
    +      const result = verifyTokenForLinkSurvey(invalidToken, surveyId);
    +      expect(result).toBeNull();
    +
    +      // Verify error logging
    +      const { logger } = await import("@formbricks/logger");
    +      expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Survey link token verification failed");
    +    });
    +
    +    test("should reject legacy survey tokens for wrong survey", () => {
    +      const correctSurveyId = "correct-survey-id";
    +      const wrongSurveyId = "wrong-survey-id";
    +
    +      // Create legacy token for one survey
    +      const legacyToken = jwt.sign(
    +        { email: `encrypted_${mockUser.email}` },
    +        TEST_NEXTAUTH_SECRET + correctSurveyId
    +      );
    +
    +      // Try to verify with different survey ID
    +      const result = verifyTokenForLinkSurvey(legacyToken, wrongSurveyId);
    +      expect(result).toBeNull();
    +    });
       });
     
       describe("verifyToken", () => {
         test("should verify valid token", async () => {
    -      const token = createToken(mockUser.id, mockUser.email);
    +      const token = createToken(mockUser.id);
           const verified = await verifyToken(token);
           expect(verified).toEqual({
    -        id: mockUser.id,
    +        id: mockUser.id, // Returns the decrypted user ID
             email: mockUser.email,
           });
         });
     
         test("should throw error if user not found", async () => {
           (prisma.user.findUnique as any).mockResolvedValue(null);
    -      const token = createToken(mockUser.id, mockUser.email);
    +      const token = createToken(mockUser.id);
           await expect(verifyToken(token)).rejects.toThrow("User not found");
         });
    +
    +    test("should throw error if NEXTAUTH_SECRET is not set", async () => {
    +      await testMissingSecretsError(verifyToken, ["any-token"], {
    +        testNextAuthSecret: true,
    +        testEncryptionKey: false,
    +        isAsync: true,
    +      });
    +    });
    +
    +    test("should throw error for invalid token signature", async () => {
    +      const invalidToken = jwt.sign({ id: "test-id" }, DIFFERENT_SECRET);
    +      await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
    +    });
    +
    +    test("should throw error if token payload is missing id", async () => {
    +      const tokenWithoutId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
    +      await expect(verifyToken(tokenWithoutId)).rejects.toThrow("Invalid token");
    +    });
    +
    +    test("should return raw id from payload", async () => {
    +      // Create token with unencrypted id
    +      const token = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
    +      const verified = await verifyToken(token);
    +      expect(verified).toEqual({
    +        id: mockUser.id, // Returns the raw ID from payload
    +        email: mockUser.email,
    +      });
    +    });
    +
    +    test("should verify legacy tokens with email-based secret", async () => {
    +      // Create legacy token with old format (NEXTAUTH_SECRET + userEmail)
    +      const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
    +
    +      const verified = await verifyToken(legacyToken);
    +      expect(verified).toEqual({
    +        id: mockUser.id, // Returns the decrypted user ID
    +        email: mockUser.email,
    +      });
    +    });
    +
    +    test("should prioritize new tokens over legacy tokens", async () => {
    +      // Create both new and legacy tokens for the same user
    +      const newToken = createToken(mockUser.id);
    +      const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
    +
    +      // New token should verify without triggering legacy path
    +      const verifiedNew = await verifyToken(newToken);
    +      expect(verifiedNew.id).toBe(mockUser.id); // Returns decrypted user ID
    +
    +      // Legacy token should trigger legacy path
    +      const verifiedLegacy = await verifyToken(legacyToken);
    +      expect(verifiedLegacy.id).toBe(mockUser.id); // Returns decrypted user ID
    +    });
    +
    +    test("should reject tokens that fail both new and legacy verification", async () => {
    +      const invalidToken = jwt.sign({ id: "encrypted_test-id" }, "wrong-secret");
    +      await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
    +
    +      // Verify both methods were attempted
    +      const { logger } = await import("@formbricks/logger");
    +      expect(logger.error).toHaveBeenCalledWith(
    +        expect.any(Error),
    +        "Token verification failed with new method"
    +      );
    +      expect(logger.error).toHaveBeenCalledWith(
    +        expect.any(Error),
    +        "Token verification failed with legacy method"
    +      );
    +    });
       });
     
       describe("verifyInviteToken", () => {
    @@ -139,6 +473,53 @@ describe("JWT Functions", () => {
         test("should throw error for invalid token", () => {
           expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
         });
    +
    +    test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
    +      await testMissingSecretsError(verifyInviteToken, ["any-token"]);
    +    });
    +
    +    test("should throw error if inviteId is missing", () => {
    +      const tokenWithoutInviteId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
    +      expect(() => verifyInviteToken(tokenWithoutInviteId)).toThrow("Invalid or expired invite token");
    +    });
    +
    +    test("should throw error if email is missing", () => {
    +      const tokenWithoutEmail = jwt.sign({ inviteId: "test-invite-id" }, TEST_NEXTAUTH_SECRET);
    +      expect(() => verifyInviteToken(tokenWithoutEmail)).toThrow("Invalid or expired invite token");
    +    });
    +
    +    test("should fall back to original values if decryption fails", () => {
    +      mockSymmetricDecrypt.mockImplementation(() => {
    +        throw new Error("Decryption failed");
    +      });
    +
    +      const inviteId = "test-invite-id";
    +      const legacyToken = jwt.sign(
    +        {
    +          inviteId,
    +          email: mockUser.email,
    +        },
    +        TEST_NEXTAUTH_SECRET
    +      );
    +
    +      const verified = verifyInviteToken(legacyToken);
    +      expect(verified).toEqual({
    +        inviteId,
    +        email: mockUser.email,
    +      });
    +    });
    +
    +    test("should throw error for token with wrong signature", () => {
    +      const invalidToken = jwt.sign(
    +        {
    +          inviteId: "test-invite-id",
    +          email: mockUser.email,
    +        },
    +        DIFFERENT_SECRET
    +      );
    +
    +      expect(() => verifyInviteToken(invalidToken)).toThrow("Invalid or expired invite token");
    +    });
       });
     
       describe("verifyEmailChangeToken", () => {
    @@ -150,22 +531,478 @@ describe("JWT Functions", () => {
           expect(result).toEqual({ id: userId, email });
         });
     
    +    test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
    +      await testMissingSecretsError(verifyEmailChangeToken, ["any-token"], { isAsync: true });
    +    });
    +
         test("should throw error if token is invalid or missing fields", async () => {
    -      // Create a token with missing fields
    -      const jwt = await import("jsonwebtoken");
    -      const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
    +      const token = jwt.sign({ foo: "bar" }, TEST_NEXTAUTH_SECRET);
    +      await expect(verifyEmailChangeToken(token)).rejects.toThrow(
    +        "Token is invalid or missing required fields"
    +      );
    +    });
    +
    +    test("should throw error if id is missing", async () => {
    +      const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
    +      await expect(verifyEmailChangeToken(token)).rejects.toThrow(
    +        "Token is invalid or missing required fields"
    +      );
    +    });
    +
    +    test("should throw error if email is missing", async () => {
    +      const token = jwt.sign({ id: "test-id" }, TEST_NEXTAUTH_SECRET);
           await expect(verifyEmailChangeToken(token)).rejects.toThrow(
             "Token is invalid or missing required fields"
           );
         });
     
         test("should return original id/email if decryption fails", async () => {
    -      // Create a token with non-encrypted id/email
    -      const jwt = await import("jsonwebtoken");
    +      mockSymmetricDecrypt.mockImplementation(() => {
    +        throw new Error("Decryption failed");
    +      });
    +
           const payload = { id: "plain-id", email: "plain@example.com" };
    -      const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string);
    +      const token = jwt.sign(payload, TEST_NEXTAUTH_SECRET);
           const result = await verifyEmailChangeToken(token);
           expect(result).toEqual(payload);
         });
    +
    +    test("should throw error for token with wrong signature", async () => {
    +      const invalidToken = jwt.sign(
    +        {
    +          id: "test-id",
    +          email: "test@example.com",
    +        },
    +        DIFFERENT_SECRET
    +      );
    +
    +      await expect(verifyEmailChangeToken(invalidToken)).rejects.toThrow();
    +    });
    +  });
    +
    +  // SECURITY SCENARIO TESTS
    +  describe("Security Scenarios", () => {
    +    describe("Algorithm Confusion Attack Prevention", () => {
    +      test("should reject 'none' algorithm tokens in verifyToken", async () => {
    +        // Create malicious token with "none" algorithm
    +        const maliciousToken =
    +          Buffer.from(
    +            JSON.stringify({
    +              alg: "none",
    +              typ: "JWT",
    +            })
    +          ).toString("base64url") +
    +          "." +
    +          Buffer.from(
    +            JSON.stringify({
    +              id: "encrypted_malicious-id",
    +            })
    +          ).toString("base64url") +
    +          ".";
    +
    +        await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
    +      });
    +
    +      test("should reject 'none' algorithm tokens in verifyTokenForLinkSurvey", () => {
    +        const maliciousToken =
    +          Buffer.from(
    +            JSON.stringify({
    +              alg: "none",
    +              typ: "JWT",
    +            })
    +          ).toString("base64url") +
    +          "." +
    +          Buffer.from(
    +            JSON.stringify({
    +              email: "encrypted_attacker@evil.com",
    +              surveyId: "test-survey-id",
    +            })
    +          ).toString("base64url") +
    +          ".";
    +
    +        const result = verifyTokenForLinkSurvey(maliciousToken, "test-survey-id");
    +        expect(result).toBeNull();
    +      });
    +
    +      test("should reject 'none' algorithm tokens in verifyInviteToken", () => {
    +        const maliciousToken =
    +          Buffer.from(
    +            JSON.stringify({
    +              alg: "none",
    +              typ: "JWT",
    +            })
    +          ).toString("base64url") +
    +          "." +
    +          Buffer.from(
    +            JSON.stringify({
    +              inviteId: "encrypted_malicious-invite",
    +              email: "encrypted_attacker@evil.com",
    +            })
    +          ).toString("base64url") +
    +          ".";
    +
    +        expect(() => verifyInviteToken(maliciousToken)).toThrow("Invalid or expired invite token");
    +      });
    +
    +      test("should reject 'none' algorithm tokens in verifyEmailChangeToken", async () => {
    +        const maliciousToken =
    +          Buffer.from(
    +            JSON.stringify({
    +              alg: "none",
    +              typ: "JWT",
    +            })
    +          ).toString("base64url") +
    +          "." +
    +          Buffer.from(
    +            JSON.stringify({
    +              id: "encrypted_malicious-id",
    +              email: "encrypted_attacker@evil.com",
    +            })
    +          ).toString("base64url") +
    +          ".";
    +
    +        await expect(verifyEmailChangeToken(maliciousToken)).rejects.toThrow();
    +      });
    +
    +      test("should reject RS256 algorithm tokens (HS256/RS256 confusion)", async () => {
    +        // Create malicious token with RS256 algorithm header but HS256 signature
    +        const maliciousHeader = Buffer.from(
    +          JSON.stringify({
    +            alg: "RS256",
    +            typ: "JWT",
    +          })
    +        ).toString("base64url");
    +
    +        const maliciousPayload = Buffer.from(
    +          JSON.stringify({
    +            id: "encrypted_malicious-id",
    +          })
    +        ).toString("base64url");
    +
    +        // Create signature using HMAC (as if it were HS256)
    +        const crypto = require("crypto");
    +        const signature = crypto
    +          .createHmac("sha256", TEST_NEXTAUTH_SECRET)
    +          .update(`${maliciousHeader}.${maliciousPayload}`)
    +          .digest("base64url");
    +
    +        const maliciousToken = `${maliciousHeader}.${maliciousPayload}.${signature}`;
    +
    +        await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
    +      });
    +
    +      test("should only accept HS256 algorithm", async () => {
    +        // Test that other valid algorithms are rejected
    +        const otherAlgorithms = ["HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"];
    +
    +        for (const alg of otherAlgorithms) {
    +          const maliciousHeader = Buffer.from(
    +            JSON.stringify({
    +              alg,
    +              typ: "JWT",
    +            })
    +          ).toString("base64url");
    +
    +          const maliciousPayload = Buffer.from(
    +            JSON.stringify({
    +              id: "encrypted_test-id",
    +            })
    +          ).toString("base64url");
    +
    +          const maliciousToken = `${maliciousHeader}.${maliciousPayload}.fake-signature`;
    +
    +          await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
    +        }
    +      });
    +    });
    +
    +    describe("Token Tampering", () => {
    +      test("should reject tokens with modified payload", async () => {
    +        const token = createToken(mockUser.id);
    +        const [header, payload, signature] = token.split(".");
    +
    +        // Modify the payload
    +        const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString());
    +        decodedPayload.id = "malicious-id";
    +        const tamperedPayload = Buffer.from(JSON.stringify(decodedPayload)).toString("base64url");
    +        const tamperedToken = `${header}.${tamperedPayload}.${signature}`;
    +
    +        await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
    +      });
    +
    +      test("should reject tokens with modified signature", async () => {
    +        const token = createToken(mockUser.id);
    +        const [header, payload] = token.split(".");
    +        const tamperedToken = `${header}.${payload}.tamperedsignature`;
    +
    +        await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
    +      });
    +
    +      test("should reject malformed tokens", async () => {
    +        const malformedTokens = [
    +          "not.a.jwt",
    +          "only.two.parts",
    +          "too.many.parts.here.invalid",
    +          "",
    +          "invalid-base64",
    +        ];
    +
    +        for (const malformedToken of malformedTokens) {
    +          await expect(verifyToken(malformedToken)).rejects.toThrow();
    +        }
    +      });
    +    });
    +
    +    describe("Cross-Survey Token Reuse", () => {
    +      test("should reject survey tokens used for different surveys", () => {
    +        const surveyId1 = "survey-1";
    +        const surveyId2 = "survey-2";
    +
    +        const token = createTokenForLinkSurvey(surveyId1, mockUser.email);
    +        const result = verifyTokenForLinkSurvey(token, surveyId2);
    +
    +        expect(result).toBeNull();
    +      });
    +    });
    +
    +    describe("Expired Tokens", () => {
    +      test("should reject expired tokens", async () => {
    +        const expiredToken = jwt.sign(
    +          {
    +            id: "encrypted_test-id",
    +            exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
    +          },
    +          TEST_NEXTAUTH_SECRET
    +        );
    +
    +        await expect(verifyToken(expiredToken)).rejects.toThrow("Invalid token");
    +      });
    +
    +      test("should reject expired email change tokens", async () => {
    +        const expiredToken = jwt.sign(
    +          {
    +            id: "encrypted_test-id",
    +            email: "encrypted_test@example.com",
    +            exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
    +          },
    +          TEST_NEXTAUTH_SECRET
    +        );
    +
    +        await expect(verifyEmailChangeToken(expiredToken)).rejects.toThrow();
    +      });
    +    });
    +
    +    describe("Encryption Key Attacks", () => {
    +      test("should fail gracefully with wrong encryption key", async () => {
    +        mockSymmetricDecrypt.mockImplementation(() => {
    +          throw new Error("Authentication tag verification failed");
    +        });
    +
    +        // Mock findUnique to only return user for correct decrypted ID, not ciphertext
    +        (prisma.user.findUnique as any).mockImplementation(({ where }: { where: { id: string } }) => {
    +          if (where.id === mockUser.id) {
    +            return Promise.resolve(mockUser);
    +          }
    +          return Promise.resolve(null); // Return null for ciphertext IDs
    +        });
    +
    +        const token = createToken(mockUser.id);
    +        // Should fail because ciphertext passed as userId won't match any user in DB
    +        await expect(verifyToken(token)).rejects.toThrow(/User not found/i);
    +      });
    +
    +      test("should handle encryption key not set gracefully", async () => {
    +        const constants = await import("@/lib/constants");
    +        const originalKey = (constants as any).ENCRYPTION_KEY;
    +        (constants as any).ENCRYPTION_KEY = undefined;
    +
    +        const token = jwt.sign(
    +          {
    +            email: "test@example.com",
    +            surveyId: "test-survey-id",
    +          },
    +          TEST_NEXTAUTH_SECRET
    +        );
    +
    +        const result = verifyTokenForLinkSurvey(token, "test-survey-id");
    +        expect(result).toBe("test@example.com");
    +
    +        // Restore
    +        (constants as any).ENCRYPTION_KEY = originalKey;
    +      });
    +    });
    +
    +    describe("SQL Injection Attempts", () => {
    +      test("should safely handle malicious user IDs", async () => {
    +        const maliciousIds = [
    +          "'; DROP TABLE users; --",
    +          "1' OR '1'='1",
    +          "admin'/*",
    +          "<script>alert('xss')</script>",
    +          "../../etc/passwd",
    +        ];
    +
    +        for (const maliciousId of maliciousIds) {
    +          mockSymmetricDecrypt.mockReturnValueOnce(maliciousId);
    +
    +          const token = jwt.sign({ id: "encrypted_malicious" }, TEST_NEXTAUTH_SECRET);
    +
    +          // The function should look up the user safely
    +          await verifyToken(token);
    +          expect(prisma.user.findUnique).toHaveBeenCalledWith({
    +            where: { id: maliciousId },
    +          });
    +        }
    +      });
    +    });
    +
    +    describe("Token Reuse and Replay Attacks", () => {
    +      test("should allow legitimate token reuse within validity period", async () => {
    +        const token = createToken(mockUser.id);
    +
    +        // First use
    +        const result1 = await verifyToken(token);
    +        expect(result1.id).toBe(mockUser.id); // Returns decrypted user ID
    +
    +        // Second use (should still work)
    +        const result2 = await verifyToken(token);
    +        expect(result2.id).toBe(mockUser.id); // Returns decrypted user ID
    +      });
    +    });
    +
    +    describe("Legacy Token Compatibility", () => {
    +      test("should handle legacy unencrypted tokens gracefully", async () => {
    +        // Legacy token with plain text data
    +        const legacyToken = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
    +        const result = await verifyToken(legacyToken);
    +
    +        expect(result.id).toBe(mockUser.id); // Returns raw ID from payload
    +        expect(result.email).toBe(mockUser.email);
    +      });
    +
    +      test("should handle mixed encrypted/unencrypted fields", async () => {
    +        mockSymmetricDecrypt
    +          .mockImplementationOnce(() => mockUser.id) // id decrypts successfully
    +          .mockImplementationOnce(() => {
    +            throw new Error("Email not encrypted");
    +          }); // email fails
    +
    +        const token = jwt.sign(
    +          {
    +            id: "encrypted_test-id",
    +            email: "plain-email@example.com",
    +          },
    +          TEST_NEXTAUTH_SECRET
    +        );
    +
    +        const result = await verifyEmailChangeToken(token);
    +        expect(result.id).toBe(mockUser.id);
    +        expect(result.email).toBe("plain-email@example.com");
    +      });
    +
    +      test("should verify old format user tokens with email-based secrets", async () => {
    +        // Simulate old token format with per-user secret
    +        const oldFormatToken = jwt.sign(
    +          { id: `encrypted_${mockUser.id}` },
    +          TEST_NEXTAUTH_SECRET + mockUser.email
    +        );
    +
    +        const result = await verifyToken(oldFormatToken);
    +        expect(result.id).toBe(mockUser.id); // Returns decrypted user ID
    +        expect(result.email).toBe(mockUser.email);
    +      });
    +
    +      test("should verify old format survey tokens with survey-based secrets", () => {
    +        const surveyId = "legacy-survey-id";
    +
    +        // Simulate old survey token format
    +        const oldFormatSurveyToken = jwt.sign(
    +          { email: `encrypted_${mockUser.email}` },
    +          TEST_NEXTAUTH_SECRET + surveyId
    +        );
    +
    +        const result = verifyTokenForLinkSurvey(oldFormatSurveyToken, surveyId);
    +        expect(result).toBe(mockUser.email);
    +      });
    +
    +      test("should gracefully handle database errors during legacy verification", async () => {
    +        // Create token that will fail new method
    +        const legacyToken = jwt.sign(
    +          { id: `encrypted_${mockUser.id}` },
    +          TEST_NEXTAUTH_SECRET + mockUser.email
    +        );
    +
    +        // Make database lookup fail
    +        (prisma.user.findUnique as any).mockRejectedValueOnce(new Error("DB connection lost"));
    +
    +        await expect(verifyToken(legacyToken)).rejects.toThrow("DB connection lost");
    +      });
    +    });
    +
    +    describe("Edge Cases and Error Handling", () => {
    +      test("should handle database connection errors gracefully", async () => {
    +        (prisma.user.findUnique as any).mockRejectedValue(new Error("Database connection failed"));
    +
    +        const token = createToken(mockUser.id);
    +        await expect(verifyToken(token)).rejects.toThrow("Database connection failed");
    +      });
    +
    +      test("should handle crypto module errors", () => {
    +        mockSymmetricEncrypt.mockImplementation(() => {
    +          throw new Error("Crypto module error");
    +        });
    +
    +        expect(() => createToken(mockUser.id)).toThrow("Crypto module error");
    +      });
    +
    +      test("should validate email format in tokens", () => {
    +        const invalidEmails = ["", "not-an-email", "missing@", "@missing-local.com", "spaces in@email.com"];
    +
    +        invalidEmails.forEach((invalidEmail) => {
    +          expect(() => createEmailToken(invalidEmail)).not.toThrow();
    +          // Note: JWT functions don't validate email format, they just encrypt/decrypt
    +          // Email validation should happen at a higher level
    +        });
    +      });
    +
    +      test("should handle extremely long inputs", () => {
    +        const longString = "a".repeat(10000);
    +
    +        expect(() => createToken(longString)).not.toThrow();
    +        expect(() => createEmailToken(longString)).not.toThrow();
    +      });
    +
    +      test("should handle special characters in user data", () => {
    +        const specialChars = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/~`";
    +
    +        expect(() => createToken(specialChars)).not.toThrow();
    +        expect(() => createEmailToken(specialChars)).not.toThrow();
    +      });
    +    });
    +
    +    describe("Performance and Resource Exhaustion", () => {
    +      test("should handle rapid token creation without memory leaks", () => {
    +        const tokens: string[] = [];
    +        for (let i = 0; i < 1000; i++) {
    +          tokens.push(createToken(`user-${i}`));
    +        }
    +
    +        expect(tokens.length).toBe(1000);
    +        expect(tokens.every((token) => typeof token === "string")).toBe(true);
    +      });
    +
    +      test("should handle rapid token verification", async () => {
    +        const token = createToken(mockUser.id);
    +
    +        const verifications: Promise<any>[] = [];
    +        for (let i = 0; i < 100; i++) {
    +          verifications.push(verifyToken(token));
    +        }
    +
    +        const results = await Promise.all(verifications);
    +        expect(results.length).toBe(100);
    +        expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
    +      });
    +    });
       });
     });
    
  • apps/web/lib/jwt.ts+210 86 modified
    @@ -1,43 +1,64 @@
    -import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
    -import { env } from "@/lib/env";
     import jwt, { JwtPayload } from "jsonwebtoken";
     import { prisma } from "@formbricks/database";
     import { logger } from "@formbricks/logger";
    +import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
    +import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
    +
    +// Helper function to decrypt with fallback to plain text
    +const decryptWithFallback = (encryptedText: string, key: string): string => {
    +  try {
    +    return symmetricDecrypt(encryptedText, key);
    +  } catch {
    +    return encryptedText; // Return as-is if decryption fails (legacy format)
    +  }
    +};
    +
    +export const createToken = (userId: string, options = {}): string => {
    +  if (!NEXTAUTH_SECRET) {
    +    throw new Error("NEXTAUTH_SECRET is not set");
    +  }
    +
    +  if (!ENCRYPTION_KEY) {
    +    throw new Error("ENCRYPTION_KEY is not set");
    +  }
     
    -export const createToken = (userId: string, userEmail: string, options = {}): string => {
    -  const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
    -  return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
    +  const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
    +  return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
     };
     export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
    -  const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
    -  return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
    +  if (!NEXTAUTH_SECRET) {
    +    throw new Error("NEXTAUTH_SECRET is not set");
    +  }
    +
    +  if (!ENCRYPTION_KEY) {
    +    throw new Error("ENCRYPTION_KEY is not set");
    +  }
    +
    +  const encryptedEmail = symmetricEncrypt(userEmail, ENCRYPTION_KEY);
    +  return jwt.sign({ email: encryptedEmail, surveyId }, NEXTAUTH_SECRET);
     };
     
     export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
    -  if (!env.NEXTAUTH_SECRET) {
    +  if (!NEXTAUTH_SECRET) {
         throw new Error("NEXTAUTH_SECRET is not set");
       }
     
    -  const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string };
    -
    -  if (!payload?.id || !payload?.email) {
    -    throw new Error("Token is invalid or missing required fields");
    +  if (!ENCRYPTION_KEY) {
    +    throw new Error("ENCRYPTION_KEY is not set");
       }
     
    -  let decryptedId: string;
    -  let decryptedEmail: string;
    +  const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as {
    +    id: string;
    +    email: string;
    +  };
     
    -  try {
    -    decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY);
    -  } catch {
    -    decryptedId = payload.id;
    +  if (!payload?.id || !payload?.email) {
    +    throw new Error("Token is invalid or missing required fields");
       }
     
    -  try {
    -    decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
    -  } catch {
    -    decryptedEmail = payload.email;
    -  }
    +  // Decrypt both fields with fallback
    +  const decryptedId = decryptWithFallback(payload.id, ENCRYPTION_KEY);
    +  const decryptedEmail = decryptWithFallback(payload.email, ENCRYPTION_KEY);
     
       return {
         id: decryptedId,
    @@ -46,127 +67,230 @@ export const verifyEmailChangeToken = async (token: string): Promise<{ id: strin
     };
     
     export const createEmailChangeToken = (userId: string, email: string): string => {
    -  const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
    -  const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
    +  if (!NEXTAUTH_SECRET) {
    +    throw new Error("NEXTAUTH_SECRET is not set");
    +  }
    +
    +  if (!ENCRYPTION_KEY) {
    +    throw new Error("ENCRYPTION_KEY is not set");
    +  }
    +
    +  const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
    +  const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
     
       const payload = {
         id: encryptedUserId,
         email: encryptedEmail,
       };
     
    -  return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
    +  return jwt.sign(payload, NEXTAUTH_SECRET, {
         expiresIn: "1d",
       });
     };
    +
     export const createEmailToken = (email: string): string => {
    -  if (!env.NEXTAUTH_SECRET) {
    +  if (!NEXTAUTH_SECRET) {
         throw new Error("NEXTAUTH_SECRET is not set");
       }
     
    -  const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
    -  return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
    +  if (!ENCRYPTION_KEY) {
    +    throw new Error("ENCRYPTION_KEY is not set");
    +  }
    +
    +  const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
    +  return jwt.sign({ email: encryptedEmail }, NEXTAUTH_SECRET);
     };
     
     export const getEmailFromEmailToken = (token: string): string => {
    -  if (!env.NEXTAUTH_SECRET) {
    +  if (!NEXTAUTH_SECRET) {
         throw new Error("NEXTAUTH_SECRET is not set");
       }
     
    -  const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as JwtPayload;
    -  try {
    -    // Try to decrypt first (for newer tokens)
    -    const decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
    -    return decryptedEmail;
    -  } catch {
    -    // If decryption fails, return the original email (for older tokens)
    -    return payload.email;
    +  if (!ENCRYPTION_KEY) {
    +    throw new Error("ENCRYPTION_KEY is not set");
       }
    +
    +  const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
    +    email: string;
    +  };
    +  return decryptWithFallback(payload.email, ENCRYPTION_KEY);
     };
     
     export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
    -  if (!env.NEXTAUTH_SECRET) {
    +  if (!NEXTAUTH_SECRET) {
         throw new Error("NEXTAUTH_SECRET is not set");
       }
    -  const encryptedInviteId = symmetricEncrypt(inviteId, env.ENCRYPTION_KEY);
    -  const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
    -  return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, env.NEXTAUTH_SECRET, options);
    +
    +  if (!ENCRYPTION_KEY) {
    +    throw new Error("ENCRYPTION_KEY is not set");
    +  }
    +
    +  const encryptedInviteId = symmetricEncrypt(inviteId, ENCRYPTION_KEY);
    +  const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
    +  return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, NEXTAUTH_SECRET, options);
     };
     
     export const verifyTokenForLinkSurvey = (token: string, surveyId: string): string | null => {
    +  if (!NEXTAUTH_SECRET) {
    +    return null;
    +  }
    +
       try {
    -    const { email } = jwt.verify(token, env.NEXTAUTH_SECRET + surveyId) as JwtPayload;
    +    let payload: JwtPayload & { email: string; surveyId?: string };
    +
    +    // Try primary method first (consistent secret)
         try {
    -      // Try to decrypt first (for newer tokens)
    -      if (!env.ENCRYPTION_KEY) {
    -        throw new Error("ENCRYPTION_KEY is not set");
    +      payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
    +        email: string;
    +        surveyId: string;
    +      };
    +    } catch (primaryError) {
    +      logger.error(primaryError, "Token verification failed with primary method");
    +
    +      // Fallback to legacy method (surveyId-based secret)
    +      try {
    +        payload = jwt.verify(token, NEXTAUTH_SECRET + surveyId, { algorithms: ["HS256"] }) as JwtPayload & {
    +          email: string;
    +        };
    +      } catch (legacyError) {
    +        logger.error(legacyError, "Token verification failed with legacy method");
    +        throw new Error("Invalid token");
           }
    -      const decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
    -      return decryptedEmail;
    -    } catch {
    -      // If decryption fails, return the original email (for older tokens)
    -      return email;
         }
    -  } catch (err) {
    +
    +    // Verify the surveyId matches if present in payload (new format)
    +    if (payload.surveyId && payload.surveyId !== surveyId) {
    +      return null;
    +    }
    +
    +    const { email } = payload;
    +    if (!email) {
    +      return null;
    +    }
    +
    +    // Decrypt email with fallback to plain text
    +    if (!ENCRYPTION_KEY) {
    +      return email; // Return as-is if encryption key not set
    +    }
    +
    +    return decryptWithFallback(email, ENCRYPTION_KEY);
    +  } catch (error) {
    +    logger.error(error, "Survey link token verification failed");
         return null;
       }
     };
     
    -export const verifyToken = async (token: string): Promise<JwtPayload> => {
    -  // First decode to get the ID
    -  const decoded = jwt.decode(token);
    -  const payload: JwtPayload = decoded as JwtPayload;
    +// Helper function to get user email for legacy verification
    +const getUserEmailForLegacyVerification = async (
    +  token: string,
    +  userId?: string
    +): Promise<{ userId: string; userEmail: string }> => {
    +  if (!userId) {
    +    const decoded = jwt.decode(token);
     
    -  if (!payload) {
    -    throw new Error("Token is invalid");
    -  }
    +    // Validate decoded token structure before using it
    +    if (
    +      !decoded ||
    +      typeof decoded !== "object" ||
    +      !decoded.id ||
    +      typeof decoded.id !== "string" ||
    +      decoded.id.trim() === ""
    +    ) {
    +      logger.error("Invalid token: missing or invalid user ID");
    +      throw new Error("Invalid token");
    +    }
     
    -  const { id } = payload;
    -  if (!id) {
    -    throw new Error("Token missing required field: id");
    +    userId = decoded.id;
       }
     
    -  // Try to decrypt the ID (for newer tokens), if it fails use the ID as-is (for older tokens)
    -  let decryptedId: string;
    -  try {
    -    decryptedId = symmetricDecrypt(id, env.ENCRYPTION_KEY);
    -  } catch {
    -    decryptedId = id;
    +  const decryptedId = decryptWithFallback(userId, ENCRYPTION_KEY);
    +
    +  // Validate decrypted ID before database query
    +  if (!decryptedId || typeof decryptedId !== "string" || decryptedId.trim() === "") {
    +    logger.error("Invalid token: missing or invalid user ID");
    +    throw new Error("Invalid token");
       }
     
    -  // If no email provided, look up the user
       const foundUser = await prisma.user.findUnique({
         where: { id: decryptedId },
       });
     
       if (!foundUser) {
    -    throw new Error("User not found");
    +    const errorMessage = "User not found";
    +    logger.error(errorMessage);
    +    throw new Error(errorMessage);
       }
     
    -  const userEmail = foundUser.email;
    +  return { userId: decryptedId, userEmail: foundUser.email };
    +};
    +
    +export const verifyToken = async (token: string): Promise<JwtPayload> => {
    +  if (!NEXTAUTH_SECRET) {
    +    throw new Error("NEXTAUTH_SECRET is not set");
    +  }
     
    -  return { id: decryptedId, email: userEmail };
    +  let payload: JwtPayload & { id: string };
    +  let userData: { userId: string; userEmail: string } | null = null;
    +
    +  // Try new method first, with smart fallback to legacy
    +  try {
    +    payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
    +      id: string;
    +    };
    +  } catch (newMethodError) {
    +    logger.error(newMethodError, "Token verification failed with new method");
    +
    +    // Get user email for legacy verification
    +    userData = await getUserEmailForLegacyVerification(token);
    +
    +    // Try legacy verification with email-based secret
    +    try {
    +      payload = jwt.verify(token, NEXTAUTH_SECRET + userData.userEmail, {
    +        algorithms: ["HS256"],
    +      }) as JwtPayload & {
    +        id: string;
    +      };
    +    } catch (legacyMethodError) {
    +      logger.error(legacyMethodError, "Token verification failed with legacy method");
    +      throw new Error("Invalid token");
    +    }
    +  }
    +
    +  if (!payload?.id) {
    +    throw new Error("Invalid token");
    +  }
    +
    +  // Get user email if we don't have it yet
    +  userData ??= await getUserEmailForLegacyVerification(token, payload.id);
    +
    +  return { id: userData.userId, email: userData.userEmail };
     };
     
     export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
    -  try {
    -    const decoded = jwt.decode(token);
    -    const payload: JwtPayload = decoded as JwtPayload;
    +  if (!NEXTAUTH_SECRET) {
    +    throw new Error("NEXTAUTH_SECRET is not set");
    +  }
     
    -    const { inviteId, email } = payload;
    +  if (!ENCRYPTION_KEY) {
    +    throw new Error("ENCRYPTION_KEY is not set");
    +  }
     
    -    let decryptedInviteId: string;
    -    let decryptedEmail: string;
    +  try {
    +    const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
    +      inviteId: string;
    +      email: string;
    +    };
     
    -    try {
    -      // Try to decrypt first (for newer tokens)
    -      decryptedInviteId = symmetricDecrypt(inviteId, env.ENCRYPTION_KEY);
    -      decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
    -    } catch {
    -      // If decryption fails, use original values (for older tokens)
    -      decryptedInviteId = inviteId;
    -      decryptedEmail = email;
    +    const { inviteId: encryptedInviteId, email: encryptedEmail } = payload;
    +
    +    if (!encryptedInviteId || !encryptedEmail) {
    +      throw new Error("Invalid token");
         }
     
    +    // Decrypt both fields with fallback to original values
    +    const decryptedInviteId = decryptWithFallback(encryptedInviteId, ENCRYPTION_KEY);
    +    const decryptedEmail = decryptWithFallback(encryptedEmail, ENCRYPTION_KEY);
    +
         return {
           inviteId: decryptedInviteId,
           email: decryptedEmail,
    
  • apps/web/modules/auth/lib/authOptions.test.ts+10 10 modified
    @@ -1,12 +1,12 @@
    +import { randomBytes } from "crypto";
    +import { Provider } from "next-auth/providers/index";
    +import { afterEach, describe, expect, test, vi } from "vitest";
    +import { prisma } from "@formbricks/database";
     import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
     import { createToken } from "@/lib/jwt";
     // Import mocked rate limiting functions
     import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
     import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
    -import { randomBytes } from "crypto";
    -import { Provider } from "next-auth/providers/index";
    -import { afterEach, describe, expect, test, vi } from "vitest";
    -import { prisma } from "@formbricks/database";
     import { authOptions } from "./authOptions";
     import { mockUser } from "./mock-data";
     import { hashPassword } from "./utils";
    @@ -31,7 +31,7 @@ vi.mock("@/lib/constants", () => ({
       SESSION_MAX_AGE: 86400,
       NEXTAUTH_SECRET: "test-secret",
       WEBAPP_URL: "http://localhost:3000",
    -  ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
    +  ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
       REDIS_URL: undefined,
       AUDIT_LOG_ENABLED: false,
       AUDIT_LOG_GET_USER_IP: false,
    @@ -261,7 +261,7 @@ describe("authOptions", () => {
           vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
           vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
     
    -      const credentials = { token: createToken(mockUser.id, mockUser.email) };
    +      const credentials = { token: createToken(mockUser.id) };
     
           await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
             "Email already verified"
    @@ -280,7 +280,7 @@ describe("authOptions", () => {
             groupId: null,
           } as any);
     
    -      const credentials = { token: createToken(mockUserId, mockUser.email) };
    +      const credentials = { token: createToken(mockUserId) };
     
           const result = await tokenProvider.options.authorize(credentials, {});
           expect(result.email).toBe(mockUser.email);
    @@ -303,7 +303,7 @@ describe("authOptions", () => {
               groupId: null,
             } as any);
     
    -        const credentials = { token: createToken(mockUserId, mockUser.email) };
    +        const credentials = { token: createToken(mockUserId) };
     
             await tokenProvider.options.authorize(credentials, {});
     
    @@ -315,7 +315,7 @@ describe("authOptions", () => {
               new Error("Maximum number of requests reached. Please try again later.")
             );
     
    -        const credentials = { token: createToken(mockUserId, mockUser.email) };
    +        const credentials = { token: createToken(mockUserId) };
     
             await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
               "Maximum number of requests reached. Please try again later."
    @@ -339,7 +339,7 @@ describe("authOptions", () => {
               groupId: null,
             } as any);
     
    -        const credentials = { token: createToken(mockUserId, mockUser.email) };
    +        const credentials = { token: createToken(mockUserId) };
     
             await tokenProvider.options.authorize(credentials, {});
     
    
  • apps/web/modules/email/index.tsx+11 11 modified
    @@ -1,3 +1,12 @@
    +import { render } from "@react-email/render";
    +import { createTransport } from "nodemailer";
    +import type SMTPTransport from "nodemailer/lib/smtp-transport";
    +import { logger } from "@formbricks/logger";
    +import type { TLinkSurveyEmailData } from "@formbricks/types/email";
    +import { InvalidInputError } from "@formbricks/types/errors";
    +import type { TResponse } from "@formbricks/types/responses";
    +import type { TSurvey } from "@formbricks/types/surveys/types";
    +import { TUserEmail, TUserLocale } from "@formbricks/types/user";
     import {
       DEBUG,
       MAIL_FROM,
    @@ -17,15 +26,6 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
     import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
     import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
     import { getTranslate } from "@/tolgee/server";
    -import { render } from "@react-email/render";
    -import { createTransport } from "nodemailer";
    -import type SMTPTransport from "nodemailer/lib/smtp-transport";
    -import { logger } from "@formbricks/logger";
    -import type { TLinkSurveyEmailData } from "@formbricks/types/email";
    -import { InvalidInputError } from "@formbricks/types/errors";
    -import type { TResponse } from "@formbricks/types/responses";
    -import type { TSurvey } from "@formbricks/types/surveys/types";
    -import { TUserEmail, TUserLocale } from "@formbricks/types/user";
     import { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
     import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
     import { VerificationEmail } from "./emails/auth/verification-email";
    @@ -111,7 +111,7 @@ export const sendVerificationEmail = async ({
     }): Promise<boolean> => {
       try {
         const t = await getTranslate();
    -    const token = createToken(id, email, {
    +    const token = createToken(id, {
           expiresIn: "1d",
         });
         const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
    @@ -136,7 +136,7 @@ export const sendForgotPasswordEmail = async (user: {
       locale: TUserLocale;
     }): Promise<boolean> => {
       const t = await getTranslate();
    -  const token = createToken(user.id, user.email, {
    +  const token = createToken(user.id, {
         expiresIn: "1d",
       });
       const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.