CVE-2026-45631
Description
Dokploy is a free, self-hostable Platform as a Service (PaaS). From 0.27.0 to before 0.29.3, a hardcoded BETTER_AUTH_SECRET fallback ("better-auth-secret-123456789") lets an unauthenticated attacker forge email verification JWTs, trigger auto-sign-in as admin, and execute commands on the host via the built-in SSH terminal. This vulnerability is fixed in 0.29.3.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Hardcoded JWT secret in Dokploy allows unauthenticated attackers to forge email verification tokens, gain admin access, and achieve RCE.
Vulnerability
A hardcoded fallback secret "better-auth-secret-123456789" is defined in packages/server/src/constants/index.ts (lines 88-89) for the BETTER_AUTH_SECRET environment variable. The install script never sets this variable, so all default self-hosted Dokploy instances from version 0.27.0 up to (but not including) 0.29.3 share the same signing secret. This allows an attacker to forge JSON Web Tokens (JWTs) used for email verification. [1]
Exploitation
An unauthenticated attacker can forge an HS256-signed JWT using the known secret. Two attack paths exist: (1) forge a verification token for an existing admin account (which has emailVerified: false because no verification email is sent), or (2) forge a token with requestType: "change-email-verification" and updateTo set to an attacker-controlled email, which works even if the admin account has emailVerified: true. The autoSignInAfterVerification: true setting then creates a database session and sets a session cookie, granting immediate admin access. [1]
Impact
Successful exploitation gives an unauthenticated remote attacker full administrative control over the Dokploy instance. This includes remote code execution on the host via the built-in SSH terminal, access to all hosted applications, databases, secrets, stored SSH keys, and registry credentials. [1]
Mitigation
The vulnerability is fixed in Dokploy version 0.29.3, released on May 9, 2026. The fix removes the hardcoded secret and introduces Docker secret support with a migration script (migrate-auth-secret.ts) to handle existing secrets. [2] Users should upgrade immediately. No workaround is available for unpatched versions.
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
11c6fdc1b4379Merge pull request #4374 from Dokploy/fix/better-auth-secret-hardcoded
17 files changed · +171 −19
apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx+4 −1 modified@@ -58,7 +58,10 @@ const BitbucketProviderSchema = z.object({ slug: z.string().optional(), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), bitbucketId: z.string().min(1, "Bitbucket Provider is required"), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().optional(),
apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx+4 −1 modified@@ -73,7 +73,10 @@ const GiteaProviderSchema = z.object({ owner: z.string().min(1, "Owner is required"), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), giteaId: z.string().min(1, "Gitea Provider is required"), watchPaths: z.array(z.string()).default([]), enableSubmodules: z.boolean().optional(),
apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx+4 −1 modified@@ -56,7 +56,10 @@ const GithubProviderSchema = z.object({ owner: z.string().min(1, "Owner is required"), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), githubId: z.string().min(1, "Github Provider is required"), watchPaths: z.array(z.string()).optional(), triggerType: z.enum(["push", "tag"]).default("push"),
apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx+4 −1 modified@@ -59,7 +59,10 @@ const GitlabProviderSchema = z.object({ id: z.number().nullable(), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), gitlabId: z.string().min(1, "Gitlab Provider is required"), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().default(false),
apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx+4 −1 modified@@ -42,7 +42,10 @@ const GitProviderSchema = z.object({ repositoryURL: z.string().min(1, { message: "Repository URL is required", }), - branch: z.string().min(1, "Branch required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), sshKey: z.string().optional(), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().default(false),
apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx+4 −1 modified@@ -58,7 +58,10 @@ const BitbucketProviderSchema = z.object({ slug: z.string().optional(), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), bitbucketId: z.string().min(1, "Bitbucket Provider is required"), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().default(false),
apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx+4 −1 modified@@ -58,7 +58,10 @@ const GiteaProviderSchema = z.object({ owner: z.string().min(1, "Owner is required"), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), giteaId: z.string().min(1, "Gitea Provider is required"), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().default(false),
apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx+4 −1 modified@@ -59,7 +59,10 @@ const GitlabProviderSchema = z.object({ gitlabPathNamespace: z.string().min(1), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), gitlabId: z.string().min(1, "Gitlab Provider is required"), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().default(false),
apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx+4 −1 modified@@ -42,7 +42,10 @@ const GitProviderSchema = z.object({ repositoryURL: z.string().min(1, { message: "Repository URL is required", }), - branch: z.string().min(1, "Branch required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), sshKey: z.string().optional(), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().default(false),
apps/dokploy/package.json+1 −0 modified@@ -14,6 +14,7 @@ "wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts", "reset-password": "node -r dotenv/config dist/reset-password.mjs", "reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs", + "migrate-auth-secret": "tsx -r dotenv/config scripts/migrate-auth-secret.ts", "dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ", "studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts", "migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
apps/dokploy/scripts/migrate-auth-secret.ts+97 −0 added@@ -0,0 +1,97 @@ +/** + * Use this command to automatically migrate the auth secret: curl -sSL https://dokploy.com/security/0.29.3.sh | bash + * Migration script: re-encrypt 2FA secrets after rotating BETTER_AUTH_SECRET. + * + * Usage: + * OLD_SECRET=<old_secret> NEW_SECRET=<new_secret> npx tsx apps/dokploy/scripts/migrate-auth-secret.ts + * + * Both OLD_SECRET and NEW_SECRET are required. + * Run this BEFORE restarting Dokploy with the new secret. + */ +import { db } from "@dokploy/server/db"; +import { twoFactor } from "@dokploy/server/db/schema"; +import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto"; +import { eq } from "drizzle-orm"; + +const OLD_SECRET = process.env.OLD_SECRET as string; +const NEW_SECRET = process.env.NEW_SECRET as string; + +if (!OLD_SECRET || !NEW_SECRET) { + console.error( + "❌ OLD_SECRET and NEW_SECRET environment variables are required.", + ); + console.error( + " Usage: OLD_SECRET=<old> NEW_SECRET=<new> npx tsx apps/dokploy/scripts/migrate-auth-secret.ts", + ); + process.exit(1); +} + +if (OLD_SECRET === NEW_SECRET) { + console.error("❌ OLD_SECRET and NEW_SECRET must be different."); + process.exit(1); +} + +async function reEncrypt( + value: string, + oldSecret: string, + newSecret: string, +): Promise<string> { + const plaintext = await symmetricDecrypt({ key: oldSecret, data: value }); + return symmetricEncrypt({ key: newSecret, data: plaintext }); +} + +async function main() { + console.log("🔍 Fetching 2FA records..."); + const records = await db.select().from(twoFactor); + + if (records.length === 0) { + console.log("✅ No 2FA records found, nothing to migrate."); + return; + } + + console.log(`📦 Found ${records.length} 2FA record(s) to migrate.`); + + let migrated = 0; + let failed = 0; + + await db.transaction(async (tx) => { + for (const record of records) { + try { + const [newSecret, newBackupCodes] = await Promise.all([ + reEncrypt(record.secret, OLD_SECRET, NEW_SECRET), + reEncrypt(record.backupCodes, OLD_SECRET, NEW_SECRET), + ]); + + await tx + .update(twoFactor) + .set({ secret: newSecret, backupCodes: newBackupCodes }) + .where(eq(twoFactor.id, record.id)); + + migrated++; + } catch (err) { + console.error( + `❌ Failed to migrate record ${record.id} (userId: ${record.userId}):`, + err, + ); + failed++; + throw err; // rollback the whole transaction + } + } + }); + + console.log(`✅ Migrated ${migrated} record(s) successfully.`); + + if (failed > 0) { + console.error( + `❌ ${failed} record(s) failed — transaction was rolled back.`, + ); + process.exit(1); + } else { + process.exit(0); + } +} + +main().catch((err) => { + console.error("❌ Migration failed:", err); + process.exit(1); +});
apps/dokploy/server/wss/listen-deployment.ts+1 −1 modified@@ -72,7 +72,7 @@ export const setupDeploymentLogsWebSocketServer = ( sshClient .on("ready", () => { const encodedPath = encodeBase64(logPath); - const command = `tail -n +1 -f "$(echo '${encodedPath}' | base64 -d)"`; + const command = `tail -n +1 -f "$(echo '${encodedPath}' | base64 -d)"`; sshClient!.exec(command, (err, stream) => { if (err) {
packages/server/src/constants/index.ts+0 −5 modified@@ -83,11 +83,6 @@ const getDockerConfig = (): Docker => { export const docker = getDockerConfig(); -// When not set, use the legacy default so 2FA remains working for users who -// enabled it before BETTER_AUTH_SECRET was introduced. -export const BETTER_AUTH_SECRET = - process.env.BETTER_AUTH_SECRET || "better-auth-secret-123456789"; - export const paths = (isServer = false) => { const BASE_PATH = isServer || process.env.NODE_ENV === "production"
packages/server/src/db/constants.ts+1 −1 modified@@ -9,7 +9,7 @@ export const { POSTGRES_PORT = "5432", } = process.env; -function readSecret(path: string): string { +export function readSecret(path: string): string { try { return fs.readFileSync(path, "utf8").trim(); } catch {
packages/server/src/lib/auth-secret.ts+28 −0 added@@ -0,0 +1,28 @@ +import { readSecret } from "../db/constants"; + +const HARDCODED_LEGACY_SECRET = "better-auth-secret-123456789"; + +const { BETTER_AUTH_SECRET, BETTER_AUTH_SECRET_FILE } = process.env; + +function resolveBetterAuthSecret(): string { + if (BETTER_AUTH_SECRET) { + return BETTER_AUTH_SECRET; + } + if (BETTER_AUTH_SECRET_FILE) { + return readSecret(BETTER_AUTH_SECRET_FILE); + } + if (process.env.NODE_ENV !== "test") { + console.warn(` +⚠️ [DEPRECATED AUTH CONFIG] +BETTER_AUTH_SECRET is not set via environment variable or Docker secret. +Falling back to the insecure hardcoded default — this is a CRITICAL SECURITY RISK. +This mode WILL BE REMOVED in a future release. + +Please migrate to Docker Secrets: + curl -sSL https://dokploy.com/security/0.29.3.sh | bash +`); + } + return HARDCODED_LEGACY_SECRET; +} + +export const betterAuthSecret = resolveBetterAuthSecret();
packages/server/src/lib/auth.ts+4 −2 modified@@ -7,7 +7,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { APIError } from "better-auth/api"; import { admin, organization, twoFactor } from "better-auth/plugins"; import { and, desc, eq } from "drizzle-orm"; -import { BETTER_AUTH_SECRET, IS_CLOUD } from "../constants"; +import { IS_CLOUD } from "../constants"; import { db } from "../db"; import * as schema from "../db/schema"; import { @@ -27,6 +27,7 @@ import { } from "../verification/send-verification-email"; import { getPublicIpWithFallback } from "../wss/utils"; import { ac, adminRole, memberRole, ownerRole } from "./access-control"; +import { betterAuthSecret } from "./auth-secret"; const { handler, api } = betterAuth({ database: drizzleAdapter(db, { @@ -38,8 +39,9 @@ const { handler, api } = betterAuth({ "/organization/create", "/organization/update", "/organization/delete", + ...(!IS_CLOUD ? ["/verify-email"] : []), ], - secret: BETTER_AUTH_SECRET, + secret: betterAuthSecret, ...(!IS_CLOUD ? { advanced: {
packages/server/src/services/docker.ts+3 −1 modified@@ -670,7 +670,9 @@ export const uploadFileToContainer = async ( } if (!destinationPathRegex.test(destinationPath)) { - throw new Error("Invalid destination path: shell metacharacters are not allowed"); + throw new Error( + "Invalid destination path: shell metacharacters are not allowed", + ); } const normalizedPath = destinationPath.startsWith("/")
Vulnerability mechanics
Root cause
"A hardcoded fallback value for the `BETTER_AUTH_SECRET` signing key allows any unauthenticated attacker to forge valid email-verification JWTs."
Attack vector
An unauthenticated attacker forges an HS256-signed JWT using the known hardcoded secret `"better-auth-secret-123456789"` [ref_id=1]. The JWT targets the `/api/auth/verify-email` endpoint with an admin email; because `autoSignInAfterVerification` is enabled and self-hosted admin users have `emailVerified: false`, the forged token creates a valid session cookie [ref_id=1]. A second path uses `requestType: "change-email-verification"` to bypass the `emailVerified` guard even when it is true [ref_id=1]. The attacker then uses the admin session to access the built-in SSH terminal, achieving remote code execution on the host [ref_id=1].
Affected code
The vulnerability originates in `packages/server/src/constants/index.ts` (lines 88-89), where the `BETTER_AUTH_SECRET` falls back to the hardcoded string `"better-auth-secret-123456789"` when no environment variable is set. The patch introduces `packages/server/src/lib/auth-secret.ts` to resolve the secret from environment, Docker secret, or a deprecated fallback, and updates `packages/server/src/lib/auth.ts` to use this new resolver instead of the constant.
What the fix does
The patch removes the hardcoded fallback from `packages/server/src/constants/index.ts` and introduces `packages/server/src/lib/auth-secret.ts`, which reads `BETTER_AUTH_SECRET` from the environment, a Docker secret file, or—only as a deprecated warning—the hardcoded string [patch_id=3104641]. The auth module now imports `betterAuthSecret` from this new resolver instead of the old constant. A migration script (`migrate-auth-secret.ts`) is provided to re-encrypt existing 2FA secrets after rotating the secret. The advisory also notes that the `/verify-email` endpoint is now excluded from the public routes list for non-cloud deployments, reducing the attack surface [ref_id=1].
Preconditions
- configThe Dokploy instance must be using the default configuration where `BETTER_AUTH_SECRET` is not set via environment variable or Docker secret, causing the hardcoded fallback to be used.
- inputThe attacker must know the target instance's admin email address (e.g., the default admin email).
- networkThe attacker must have network access to the Dokploy API endpoint `/api/auth/verify-email`.
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.