Guide

Zeabur, Railway and TiDB Cloud breaches: rotate leaked environment variables in the right order

By Aman Singh · Published · 12 min read

In one October week, three developer platforms disclosed breaches that reached customer secrets. Zeabur says an attacker read the environment variables of every service on its shared clusters, and it is shutting down on 31 December 2026. Railway says up to 1.85% of workspaces had environment variables decrypted. PingCAP, which runs TiDB Cloud, says encrypted API-key values were copied. Railway believes the attacks on platforms were aimed at AI provider keys.

If you deployed on any of them, every API key, database password and signing secret you stored there should be treated as known to someone else. This guide gives the order to work in, a script that turns your exported variables into a prioritised rotation plan, a query that finds what an intruder may have left in a PostgreSQL database, and the Zeabur migration steps with the dates that matter. Everything below was checked against the companies' own reports and tested on 24 October 2026.

Short on time, or moving several apps? Get help rotating keys and migrating →

Rotate first, migrate second: copying old values to a new platform carries the leak with you.

What leaked, platform by platform

ZeaburRailwayTiDB Cloud (PingCAP)
When14 to 15 October 202620 to 22 October 20269 September to 7 October 2026
Environment variablesAll services on shared clusters treated as leaked; 3,218,965 variable records read from the platform databaseAt most 1.85% of workspaces (about 100,000) had some variables decrypted; Railway cannot say whichNot a hosting platform; encrypted TiDB Cloud API-key values were copied, none shown to be decrypted
Other data321,810 accounts (email, username, phone); commands run inside 865 database services; GitHub App private key readUser accounts not accessed; source code not yet ruled outNames, emails, SQL editor text and errors, billing records, source code
StatusShutting down on 31 December 2026Running; builds and API restored on 22 OctoberRunning; affected customers to be notified
ReportPreliminary report, 19 OctPreliminary report, 22 OctIncident report, 19 Oct
  • Zeabur: the attacker also signed in as root on 101 GCP Dedicated Servers and added SSH keys to 50 of them (the key comment is @zeabur-pentest). Zeabur says to treat those servers as compromised and rebuild them. Database backups in Taipei (tpe1) and Hong Kong (hkg1) should be treated as leaked.
  • Railway: it worked with GitHub, GitLab, Anthropic, OpenAI, OpenRouter, Stripe and others to revoke keys, sometimes for every key on the platform. A key that suddenly stops working may already have been revoked for you. Commits pushed while deployments were paused on 21 October were not deployed; redeploy the latest commit.
  • TiDB Cloud: anything you typed into the SQL editor was in the copied data. If you ever pasted a password or personal data into a query there, treat it as exposed.

Why the usual rotation advice is wrong here

Most rotation guides are written for planned rotation, where the goal is zero downtime: create a new key, move traffic over slowly, retire the old one later. Applied to a leaked key, that keeps the attacker's copy working:

  • Stripe: when you rotate a key, "both the old and new keys work for up to 7 days" unless you set the expiry to Now (Stripe docs).
  • AWS: the access key guide suggests waiting several days and checking the old key's last use before deactivating it (AWS docs).

Zeabur's instruction is the right one for a confirmed leak: revoke the old credential with the provider first, then create the new one and redeploy. PingCAP says the same for keys you suspect were misused: revoke immediately, without creating a replacement first. Accept a few minutes of errors rather than days of exposure. Where you only rotate as a precaution, overlapping old and new keys for an hour is reasonable.

Turn your variables into a rotation plan

Start with a full list. Both CLIs can export a service's variables as JSON; the exported file contains raw values, so keep it on your machine and delete it when you are done.

Terminal
# Zeabur (CLI 0.24.0): log in, pick the project, export each service
npx zeabur@latest auth login
npx zeabur@latest context set project --name my-project
npx zeabur@latest variable list --name api --json > api.json

# Railway (CLI 5.71.0)
npx @railway/cli login
npx @railway/cli link
npx @railway/cli variable list --service api --json > api.json

The script below reads those exports (or any .env file), works out what each secret is, groups values reused across services, flags database URLs that are reachable from outside a private network, and prints the order to rotate in with the place to do it. It never prints a value: only a known prefix and the length. Node.js 18 or later, no dependencies.

env-rotation-plan.mjs
#!/usr/bin/env node
// env-rotation-plan.mjs: turn exported environment variables into a rotation plan.
// Reads .env files and the JSON from `zeabur variable list --json` or
// `railway variable list --json`, works out what each secret is, and prints
// what to rotate first and where. Values are never printed, only masked.
//   node env-rotation-plan.mjs api.env worker.json
//   node env-rotation-plan.mjs --json api.env > plan.json
// Exit code: 0 = no secrets found, 1 = secrets to rotate, 2 = bad input.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

/** Parses .env / KEY=value text. Ignores comments and `export `. */
export function parseEnv(text) {
  const out = [];
  for (const raw of text.split(/\r?\n/)) {
    const line = raw.trim();
    if (!line || line.startsWith("#")) continue;
    const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/.exec(line);
    if (!m) continue;
    let value = m[2];
    if (/^(['"]).*\1$/.test(value)) value = value.slice(1, -1);
    out.push({ key: m[1], value });
  }
  return out;
}

/** Accepts Zeabur ({variables:[{key,value}]}), Railway ({KEY:value}) or [{key,value}] JSON. */
export function parseJson(text) {
  const data = JSON.parse(text);
  const list = Array.isArray(data)
    ? data
    : Array.isArray(data.variables)
      ? [...data.variables, ...(data.readonlyVariables ?? [])]
      : Object.entries(data).map(([key, value]) => ({ key, value }));
  return list
    .map((v) => ({ key: v.key ?? v.name, value: v.value }))
    .filter((v) => typeof v.key === "string")
    .map((v) => ({ key: v.key, value: v.value == null ? null : String(v.value) }));
}

// Priority 1 is the most urgent. Ordered by how fast a stolen value turns
// into money or wider access. AI keys sit at 2 because the October 2026
// platform attacks were after them.
const RULES = [
  { id: "wallet", p: 1, name: "Crypto wallet key or seed phrase", test: (k, v) => /MNEMONIC|SEED_PHRASE|WALLET/.test(k) || (/PRIVATE_KEY/.test(k) && /^(0x)?[0-9a-fA-F]{64}$/.test(v)) || /^([a-z]+ ){11}[a-z]+$|^([a-z]+ ){23}[a-z]+$/.test(v), action: "Move funds to a new wallet first, then retire the key. A wallet key cannot be revoked." },
  { id: "stripe", p: 1, name: "Stripe live secret or restricted key", prefix: /^(sk|rk)_live_/, action: "On the API keys page: ... menu > Rotate key, with Expiry set to Now (the default grace period keeps the old key working). Check its request logs.", where: "https://dashboard.stripe.com/apikeys" },
  { id: "aws", p: 1, name: "AWS access key pair", test: (k, v) => /^(AKIA|ASIA)[0-9A-Z]{16}$/.test(v) || /^AWS_SECRET_ACCESS_KEY$/.test(k), action: "Deactivate the key now (aws iam update-access-key --status Inactive), create a new one, then check CloudTrail and billing.", where: "https://console.aws.amazon.com/iam/" },
  { id: "gcp", p: 1, name: "Google Cloud service account key", test: (k, v) => /"type"\s*:\s*"service_account"/.test(v), action: "Delete the key in IAM > Service accounts > Keys, create a new one, check audit logs.", where: "https://console.cloud.google.com/iam-admin/serviceaccounts" },
  { id: "privkey", p: 2, name: "Private key (PEM)", test: (k, v) => /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(v), action: "Generate a new key pair where it is issued (GitHub App, SSH, JWT signing, TLS) and delete the old one." },
  { id: "github", p: 2, name: "GitHub token", prefix: /^(ghp_|github_pat_|gho_|ghu_|ghs_|ghr_)/, action: "Revoke it under Settings > Developer settings, create a new one with the least scope, check the audit log.", where: "https://github.com/settings/tokens" },
  { id: "gitlab", p: 2, name: "GitLab token", prefix: /^glpat-/, action: "Revoke it under Access tokens and create a new one.", where: "https://gitlab.com/-/user_settings/personal_access_tokens" },
  { id: "registry", p: 2, name: "Container registry credential", test: (k) => /DOCKER|REGISTRY|GHCR/.test(k) && /PASS|TOKEN|SECRET/.test(k), action: "Revoke the token at the registry, create a new one, check the registry audit log for pushed images." },
  { id: "database", p: 3, name: "Database URL with password", test: (k, v) => /^(postgres(ql)?|mysql|mariadb|mongodb(\+srv)?|rediss?|amqps?):\/\/[^:/\s]*:[^@\s]+@/.test(v), action: "Change the password inside the database (ALTER ROLE / ALTER USER), update every service, and close public access." },
  { id: "dbpass", p: 3, name: "Database password", test: (k) => /(DB|DATABASE|POSTGRES|PG|MYSQL|MONGO|REDIS)\w*(PASSWORD|PASS)$/.test(k), action: "Change the password inside the database and update every service that uses it." },
  { id: "openai", p: 2, name: "OpenAI API key", prefix: /^sk-(proj|svcacct|admin)-/, action: "Delete it under API keys and create a new one; check Usage for spend you do not recognise.", where: "https://platform.openai.com/api-keys" },
  { id: "anthropic", p: 2, name: "Anthropic API key", prefix: /^sk-ant-/, action: "Delete it in the Console under API keys and create a new one; check usage.", where: "https://console.anthropic.com/settings/keys" },
  { id: "openrouter", p: 2, name: "OpenRouter key", prefix: /^sk-or-v1-/, action: "Delete it under Keys and create a new one; check activity.", where: "https://openrouter.ai/settings/keys" },
  { id: "googleapi", p: 2, name: "Google API key (Gemini, Maps)", prefix: /^AIza/, action: "Delete or regenerate it under APIs & Services > Credentials and add restrictions.", where: "https://console.cloud.google.com/apis/credentials" },
  { id: "ai-generic", p: 2, name: "AI provider key", test: (k) => /(OPENAI|ANTHROPIC|CLAUDE|GEMINI|MISTRAL|GROQ|DEEPSEEK|COHERE|TOGETHER|REPLICATE|HUGGING|PERPLEXITY|XAI)\w*(KEY|TOKEN)/.test(k), action: "Delete it with the provider, create a new one, check usage and billing." },
  { id: "slack", p: 5, name: "Slack token or webhook", test: (k, v) => /^xox[abpr]-/.test(v) || /hooks\.slack\.com\/services\//.test(v), action: "Revoke the token or regenerate the webhook URL in the Slack app settings." },
  { id: "email", p: 5, name: "Email or SMS provider key", test: (k, v) => /^SG\.[\w-]{20,}\.[\w-]{20,}$/.test(v) || /^re_[A-Za-z0-9_]{20,}$/.test(v) || /(SENDGRID|RESEND|MAILGUN|POSTMARK|TWILIO|SES_|SMTP)\w*(KEY|TOKEN|PASS|SECRET)/.test(k), action: "Revoke it with the provider and create a new one; attackers use these for phishing from your domain." },
  { id: "stripe-webhook", p: 6, name: "Stripe webhook signing secret", prefix: /^whsec_/, action: "Roll the signing secret on the webhook endpoint in the Dashboard (Workbench > Webhooks).", where: "https://dashboard.stripe.com/webhooks" },
  { id: "n8n", p: 6, name: "n8n encryption key", test: (k) => k === "N8N_ENCRYPTION_KEY", action: "Set a new key, then recreate every credential stored in n8n; the old ones can be decrypted with the leaked key." },
  { id: "signing", p: 6, name: "Session, JWT or app signing secret", test: (k) => /^(JWT_SECRET|JWT_SIGNING_KEY|SESSION_SECRET|COOKIE_SECRET|NEXTAUTH_SECRET|AUTH_SECRET|BETTER_AUTH_SECRET|SECRET_KEY_BASE|APP_KEY|DJANGO_SECRET_KEY|SECRET_KEY|FLASK_SECRET_KEY)$/.test(k), action: "Generate a new value. Anyone with the old one can forge sessions or tokens; rotating it signs everyone out." },
  { id: "encryption", p: 6, name: "Encryption key", test: (k) => /ENCRYPTION_KEY|ENCRYPT_KEY|CIPHER_KEY|MASTER_KEY|DATA_KEY/.test(k), action: "Data encrypted with it should be treated as readable: decrypt and re-encrypt with a new key, and rotate any secrets stored that way." },
  { id: "stripe-test", p: 8, name: "Stripe test key", prefix: /^(sk|rk)_test_/, action: "Low risk, but rotate it with the live key so old values stop working." },
  { id: "generic", p: 7, name: "Other secret", test: (k) => /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|API_?KEY|ACCESS_KEY|AUTH)/.test(k), action: "Find who issued it, revoke it there and create a new one." },
];
const NOT_SECRET = /^(PORT|HOST|HOSTNAME|NODE_ENV|ENV|TZ|LANG|LOG_LEVEL|DEBUG|PUBLIC_.*|NEXT_PUBLIC_.*|VITE_.*|.*_URL|.*_URI|.*_ENDPOINT|.*_REGION|.*_BUCKET|.*_PUBLISHABLE_KEY|.*_ID)$/;

/** Returns the matching rule for one variable, or null if it does not look secret. */
export function classify(key, value) {
  const k = key.toUpperCase();
  const v = value ?? "";
  for (const rule of RULES) {
    if (rule.prefix ? rule.prefix.test(v) : rule.test(k, v)) {
      // A *_URL without credentials only matches the database rule above.
      if (rule.id === "generic" && NOT_SECRET.test(k)) return null;
      return rule;
    }
  }
  return null;
}

/** Shows only what identifies the kind of secret: a known prefix and the length. */
export function mask(rule, value) {
  if (value == null) return "(sealed, value not readable)";
  // An AWS key ID's first four letters only say what kind of key it is.
  const pre = rule.prefix ? (rule.prefix.exec(value)?.[0] ?? "") : (/^(AKIA|ASIA)/.exec(value)?.[0] ?? "");
  if (rule.id === "database") return value.replace(/:\/\/([^:/\s]*):[^@\s]+@/, "://$1:****@").replace(/\?.*$/, "");
  return `${pre}${"*".repeat(4)} (${value.length} chars)`;
}

/** True when a database URL points somewhere other than a private network name. */
export function publicDatabase(value) {
  const host = /@([^:/?\s]+)/.exec(value ?? "")?.[1] ?? "";
  if (!host) return false;
  return !/(\.internal|\.local|^localhost|^127\.|^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\.)/.test(host);
}

/** Builds the plan: one entry per distinct secret value, with every place it is used. */
export function plan(sources) {
  const byValue = new Map();
  for (const { file, vars } of sources) {
    for (const { key, value } of vars) {
      const rule = classify(key, value);
      if (!rule) continue;
      // An AWS key ID and its secret are one pair: rotate them together.
      const id = rule.id === "aws"
        ? `aws:${file}`
        : value == null ? `${file}:${key}` : createHash("sha256").update(value).digest("hex");
      const entry = byValue.get(id) ?? { rule, masked: mask(rule, value), uses: [], publicDb: false };
      entry.uses.push(`${file}: ${key}`);
      if (rule.id === "database" && publicDatabase(value)) entry.publicDb = true;
      byValue.set(id, entry);
    }
  }
  return [...byValue.values()].sort((a, b) => a.rule.p - b.rule.p || a.uses[0].localeCompare(b.uses[0]));
}

function load(file) {
  const text = readFileSync(file, "utf8");
  const name = path.basename(file);
  return { file: name, vars: text.trimStart().startsWith("{") || text.trimStart().startsWith("[") ? parseJson(text) : parseEnv(text) };
}

function main(argv) {
  const asJson = argv.includes("--json");
  const files = argv.filter((a) => a !== "--json");
  if (files.length === 0) {
    console.error("usage: node env-rotation-plan.mjs [--json] <file.env|vars.json> ...");
    return 2;
  }
  let sources;
  try {
    sources = files.map(load);
  } catch (err) {
    console.error(`Could not read input: ${err.message}`);
    return 2;
  }
  const entries = plan(sources);
  if (asJson) {
    console.log(JSON.stringify(entries.map((e) => ({ priority: e.rule.p, kind: e.rule.name, masked: e.masked, usedIn: e.uses, publicDatabase: e.publicDb, action: e.rule.action, where: e.rule.where ?? null })), null, 2));
    return entries.length ? 1 : 0;
  }
  const total = sources.reduce((n, s) => n + s.vars.length, 0);
  console.log(`Read ${total} variables from ${sources.length} file(s); ${entries.length} distinct secrets to rotate.\n`);
  entries.forEach((e, i) => {
    console.log(`${String(i + 1).padStart(2)}. [P${e.rule.p}] ${e.rule.name}  ${e.masked}`);
    const note = e.uses.length < 2 ? "" : e.rule.id === "aws" ? "  (one key pair: rotate together)" : "  (same value: rotate once, update all)";
    console.log(`    used in: ${e.uses.join(", ")}${note}`);
    if (e.publicDb) console.log("    WARNING: host is reachable from outside a private network; restrict access");
    console.log(`    do: ${e.rule.action}`);
    if (e.rule.where) console.log(`    at: ${e.rule.where}`);
  });
  if (entries.length) console.log("\nRevoke at the provider first, then set the new value on the new platform and redeploy.");
  return entries.length ? 1 : 0;
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  process.exitCode = main(process.argv.slice(2));
}
Terminal
node env-rotation-plan.mjs api.json worker.json n8n.env
node env-rotation-plan.mjs --json api.json > plan.json    # machine-readable
Output for an API, a worker and n8n (fake values; trimmed)
Read 19 variables from 3 file(s); 11 distinct secrets to rotate.

 1. [P1] AWS access key pair  AKIA**** (20 chars)
    used in: api.json: AWS_ACCESS_KEY_ID, api.json: AWS_SECRET_ACCESS_KEY  (one key pair: rotate together)
    do: Deactivate the key now (aws iam update-access-key --status Inactive), create a new one, then check CloudTrail and billing.
 2. [P1] Stripe live secret or restricted key  sk_live_**** (107 chars)
    used in: api.json: STRIPE_SECRET_KEY
    do: On the API keys page: ... menu > Rotate key, with Expiry set to Now (the default grace period keeps the old key working).
 3. [P2] OpenAI API key  sk-proj-**** (128 chars)
    used in: api.json: OPENAI_API_KEY, worker.json: OPENAI_API_KEY  (same value: rotate once, update all)
 4. [P2] Anthropic API key  sk-ant-**** (108 chars)
 5. [P2] GitHub token  github_pat_**** (81 chars)
 6. [P3] Database URL with password  postgresql://app:****@db-hnd1.example-host.com:31842/app
    WARNING: host is reachable from outside a private network; restrict access
 7. [P3] Database password  **** (24 chars)
 8. [P5] Email or SMS provider key  **** (33 chars)
 9. [P6] Session, JWT or app signing secret  **** (48 chars)
10. [P6] Stripe webhook signing secret  whsec_**** (38 chars)
11. [P6] n8n encryption key  **** (32 chars)

Revoke at the provider first, then set the new value on the new platform and redeploy.
Show the tests (env-rotation-plan.test.mjs, 7 passing)
env-rotation-plan.test.mjs
// Run: node --test env-rotation-plan.test.mjs
// Fake secrets are assembled at runtime so this file never contains a string
// that secret scanners would treat as a real key.
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { classify, mask, parseEnv, parseJson, plan, publicDatabase } from "./env-rotation-plan.mjs";

const fake = (prefix, n = 32) => prefix + "x".repeat(n);
const STRIPE = fake("sk_" + "live_", 99);
const OPENAI = fake("sk-" + "proj-", 40);
const ANTHROPIC = fake("sk-" + "ant-api03-", 40);
const AWS_ID = "AKIA" + "ABCDEFGHIJKLMNOP";
const DB = "postgresql://app:" + "s3cr3tpw" + "@db.example.com:5432/app?sslmode=require";
const DB_PRIVATE = "postgresql://app:" + "s3cr3tpw" + "@postgresql.zeabur.internal:5432/app";

test("parses .env with comments, export and quotes", () => {
  const vars = parseEnv('# comment\nexport A=1\nB="two words"\nC=\'x\'\n\nnot a var\n');
  assert.deepEqual(vars, [{ key: "A", value: "1" }, { key: "B", value: "two words" }, { key: "C", value: "x" }]);
});

test("parses Zeabur and Railway JSON, including sealed variables", () => {
  const zeabur = parseJson(JSON.stringify({ variables: [{ key: "A", value: "1", serviceID: "s" }], readonlyVariables: [{ key: "B", value: "2" }] }));
  assert.deepEqual(zeabur, [{ key: "A", value: "1" }, { key: "B", value: "2" }]);
  const railway = parseJson(JSON.stringify({ A: "1", SEALED: null }));
  assert.deepEqual(railway, [{ key: "A", value: "1" }, { key: "SEALED", value: null }]);
});

test("recognises common secrets and skips plain config", () => {
  assert.equal(classify("STRIPE_SECRET_KEY", STRIPE).id, "stripe");
  assert.equal(classify("AWS_ACCESS_KEY_ID", AWS_ID).id, "aws");
  assert.equal(classify("DATABASE_URL", DB).id, "database");
  assert.equal(classify("OPENAI_API_KEY", OPENAI).id, "openai");
  assert.equal(classify("ANTHROPIC_API_KEY", ANTHROPIC).id, "anthropic");
  assert.equal(classify("JWT_SECRET", "abc").id, "signing");
  assert.equal(classify("N8N_ENCRYPTION_KEY", "abc").id, "n8n");
  assert.equal(classify("SOME_VENDOR_TOKEN", "abc").id, "generic");
  for (const [k, v] of [["PORT", "3000"], ["APP_URL", "https://example.com"], ["NEXT_PUBLIC_SITE", "x"], ["STRIPE_PUBLISHABLE_KEY", "pk_live_x"], ["AUTH_URL", "https://x"]]) {
    assert.equal(classify(k, v), null, k);
  }
});

test("masks never contain the secret itself", () => {
  for (const [k, v] of [["S", STRIPE], ["O", OPENAI], ["DATABASE_URL", DB]]) {
    const m = mask(classify(k, v), v);
    assert.ok(!m.includes("x".repeat(8)) && !m.includes("s3cr3tpw"), m);
  }
  assert.equal(mask(classify("DATABASE_URL", DB), DB), "postgresql://app:****@db.example.com:5432/app");
});

test("groups a value reused across services and flags public databases", () => {
  const entries = plan([
    { file: "api.env", vars: [{ key: "STRIPE_SECRET_KEY", value: STRIPE }, { key: "DATABASE_URL", value: DB }] },
    { file: "worker.json", vars: [{ key: "STRIPE_KEY", value: STRIPE }, { key: "DATABASE_URL", value: DB_PRIVATE }] },
  ]);
  const stripe = entries.find((e) => e.rule.id === "stripe");
  assert.deepEqual(stripe.uses, ["api.env: STRIPE_SECRET_KEY", "worker.json: STRIPE_KEY"]);
  assert.equal(entries[0].rule.id, "stripe", "money comes first");
  assert.equal(publicDatabase(DB), true);
  assert.equal(publicDatabase(DB_PRIVATE), false);
});

test("treats an AWS key ID and secret as one pair", () => {
  const entries = plan([{ file: "api.env", vars: [{ key: "AWS_ACCESS_KEY_ID", value: AWS_ID }, { key: "AWS_SECRET_ACCESS_KEY", value: "y".repeat(40) }] }]);
  assert.equal(entries.length, 1);
  assert.deepEqual(entries[0].uses, ["api.env: AWS_ACCESS_KEY_ID", "api.env: AWS_SECRET_ACCESS_KEY"]);
  assert.equal(entries[0].masked, "AKIA**** (20 chars)");
});

test("CLI prints a plan without any raw value and exits 1", () => {
  const dir = mkdtempSync(path.join(tmpdir(), "envplan-"));
  const file = path.join(dir, "api.env");
  writeFileSync(file, `STRIPE_SECRET_KEY=${STRIPE}\nOPENAI_API_KEY=${OPENAI}\nDATABASE_URL=${DB}\nPORT=3000\n`);
  const script = path.join(path.dirname(fileURLToPath(import.meta.url)), "env-rotation-plan.mjs");
  let out = "";
  let code = 0;
  try {
    out = execFileSync(process.execPath, [script, file], { encoding: "utf8" });
  } catch (err) {
    out = err.stdout;
    code = err.status;
  }
  assert.equal(code, 1);
  assert.match(out, /3 distinct secrets/);
  for (const secret of [STRIPE, OPENAI, "s3cr3tpw"]) assert.ok(!out.includes(secret));
});

Railway lists sealed variables with a null value because nobody can read them back; the script still lists them by name so you can rotate them. If you would rather not run anything, paste a .env into the free API key and secret scanner, which builds a similar checklist in the browser without uploading anything.

Rotate each kind of secret

Money and cloud accounts first

Stripe: on the API keys page, open the menu next to the key, choose Rotate key and set Expiry to Now. Do the same for restricted keys, then open the key's request logs to look for calls you did not make. Roll webhook signing secrets on each endpoint too.

AWS: deactivate first, then create, check, and delete:

Terminal
# Stop the leaked key at once, then make a new one
aws iam update-access-key --user-name app-deployer --access-key-id AKIA... --status Inactive
aws iam create-access-key --user-name app-deployer
# Was it used, and from where?
aws iam get-access-key-last-used --access-key-id AKIA...
# After the app runs on the new key
aws iam delete-access-key --user-name app-deployer --access-key-id AKIA...

Check CloudTrail and the bill for the days since the leak: Zeabur users from 14 October (Dedicated Server users from 1 September), Railway users from 20 October. Crypto wallet keys cannot be revoked at all: move the funds to a new wallet first.

AI provider keys

These were the target. Delete each key in the provider's console (OpenAI, Anthropic, OpenRouter, Google AI Studio), create a new one, and check usage for spend you do not recognise. Zeabur says it will review compensation requests for third-party charges caused by its incident through a support ticket.

Code, registries and private keys

  • Revoke GitHub and GitLab tokens and create new ones with the least scope you need. Zeabur users should check the GitHub audit log for actions by the Zeabur GitHub App between 15:05 and 18:54 UTC on 15 October: clones, pushes, new deploy keys, changed webhooks.
  • Railway users: review installed GitHub app access and your registry's audit log (for example Docker Hub organisation activity).
  • Regenerate private keys (GitHub App keys, SSH keys, JWT signing keys) where they are issued and delete the old ones.

Databases

Change the password inside the database, not just the variable, and update every service that connects. Then remove public network access: both Railway and Zeabur recommend it.

SQL
-- PostgreSQL: new password for the app role, then update every service
ALTER ROLE app WITH PASSWORD 'a-new-long-random-password';
-- MySQL / MariaDB
ALTER USER 'app'@'%' IDENTIFIED BY 'a-new-long-random-password';

On Zeabur shared clusters the attacker ran commands inside databases, so also replace password hashes, sessions and tokens your app stores in its own tables, and read the next section before reusing the database. For TiDB Cloud, organisation owners should replace management API keys under Organization Settings > API Keys: create the new key, update your scripts, then delete the old one.

Signing and encryption secrets

Anyone holding your old JWT_SECRET, SESSION_SECRET or NEXTAUTH_SECRET can forge logins. Generate new values; every user is signed out once, which is the point.

Terminal
# A new random value for JWT_SECRET, SESSION_SECRET, NEXTAUTH_SECRET and similar
node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"

Encryption keys are harder: data encrypted with a leaked key should be treated as readable. Decrypt and re-encrypt it with a new key, and rotate whatever secrets were stored that way. For n8n, Zeabur says to replace N8N_ENCRYPTION_KEY and recreate every third-party credential, because the stored ones can be decrypted with the old key (n8n's own documentation does not describe a rotation procedure). This is the same reason to keep users' API keys encrypted with a key that lives outside the app.

Check databases for planted objects

Zeabur found a planted program connecting to an external host in at least two PostgreSQL databases, and tells shared-cluster users to look for unknown roles, functions, triggers, scheduled jobs and extensions. This read-only query lists all of those, plus two ways a database can keep sending data elsewhere that are easy to miss: logical replication and foreign servers.

pg-audit.sql
-- pg-audit.sql: list objects an intruder could leave behind in a PostgreSQL
-- database. Read-only. Run it as the database owner or a superuser:
--   psql "$DATABASE_URL" -f pg-audit.sql
-- Anything you do not recognise is a reason to rebuild the database from your
-- own schema and import data only.

-- 1. Roles that can log in or hold powerful rights (built-in pg_* roles excluded)
SELECT 'role' AS kind, rolname AS name,
       concat_ws(', ',
         CASE WHEN rolsuper THEN 'SUPERUSER' END,
         CASE WHEN rolcreaterole THEN 'CREATEROLE' END,
         CASE WHEN rolreplication THEN 'REPLICATION' END,
         CASE WHEN rolbypassrls THEN 'BYPASSRLS' END,
         CASE WHEN rolcanlogin THEN 'LOGIN' END,
         CASE WHEN pg_has_role(oid, 'pg_execute_server_program', 'MEMBER') AND NOT rolsuper THEN 'CAN RUN SERVER PROGRAMS' END
       ) AS detail
FROM pg_roles
WHERE rolname !~ '^pg_' AND (rolcanlogin OR rolsuper OR rolcreaterole OR rolreplication)

UNION ALL
-- 2. Functions outside the system schemas; the risky ones first
SELECT 'function', n.nspname || '.' || p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')',
       concat_ws(', ', l.lanname,
         CASE WHEN p.prosecdef THEN 'SECURITY DEFINER' END,
         CASE WHEN p.prosrc ~* '(program|dblink|http|curl|wget|lo_export|pg_read_server_files|pg_write_server_files|copy .* to)' THEN 'REACHES OUTSIDE THE DATABASE' END)
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
JOIN pg_language l ON l.oid = p.prolang
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname !~ '^pg_toast'
  AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e')

UNION ALL
-- 3. Triggers on tables (internal constraint triggers excluded)
SELECT 'trigger', t.tgname || ' ON ' || t.tgrelid::regclass, 'calls ' || t.tgfoid::regproc
FROM pg_trigger t WHERE NOT t.tgisinternal

UNION ALL
-- 4. Event triggers fire on DDL for the whole database
SELECT 'event trigger', evtname, evtevent || ' calls ' || evtfoid::regproc FROM pg_event_trigger

UNION ALL
-- 5. Extensions other than the default plpgsql
SELECT 'extension', extname, extversion FROM pg_extension WHERE extname <> 'plpgsql'

UNION ALL
-- 6. Foreign servers (postgres_fdw, dblink) point at other hosts
SELECT 'foreign server', srvname, array_to_string(srvoptions, ', ') FROM pg_foreign_server

UNION ALL
-- 7. Logical replication can copy every change to another server
SELECT 'publication', pubname, CASE WHEN puballtables THEN 'ALL TABLES' ELSE 'some tables' END FROM pg_publication
UNION ALL
SELECT 'subscription', subname, CASE WHEN subenabled THEN 'enabled' ELSE 'disabled' END FROM pg_subscription

ORDER BY 1, 2;

-- Run separately if they apply:
-- Where a subscription connects to (superuser only):
--   SELECT subname, regexp_replace(subconninfo, 'password=\S+', 'password=****') FROM pg_subscription;
-- Scheduled jobs, if the pg_cron extension is installed:
--   SELECT jobid, schedule, command, username FROM cron.job;
Terminal
psql "$DATABASE_URL" -f pg-audit.sql

To test it, I ran it on a clean PostgreSQL 18 database (two rows: postgres and the app role), then planted a backdoor role, a trigger function that pipes new rows to curl, an event trigger, a foreign server, a publication and a subscription. Nothing planted was ever run. The query listed every object, both as superuser and as the plain app role:

Output after planting
event trigger   keep_alive                       ddl_command_end calls on_ddl
extension       postgres_fdw                     1.2
foreign server  mirror                           host=collector.invalid, dbname=loot
function        public.on_ddl()                  plpgsql
function        public.sync_audit()              plpgsql, SECURITY DEFINER, REACHES OUTSIDE THE DATABASE
publication     all_changes                      ALL TABLES
role            app_user                         LOGIN
role            backup_svc                       LOGIN, CAN RUN SERVER PROGRAMS
role            postgres                         SUPERUSER, CREATEROLE, REPLICATION, BYPASSRLS, LOGIN
subscription    pull_back                        disabled
trigger         users_sync ON users              calls sync_audit

If anything is unfamiliar, do not try to clean the database. Create a new one from your own migrations and copy the data only:

Terminal (standard pg_dump flags; adjust for your setup)
# 1. Data only from the old database: no roles, functions, triggers or grants
pg_dump "$OLD_DATABASE_URL" --data-only --no-owner --no-privileges --format=custom -f data.dump

# 2. Create the schema on the new database from your own migrations
#    (prisma migrate deploy, drizzle-kit migrate, rails db:schema:load, ...)

# 3. Load the data; --disable-triggers needs superuser on the new database
pg_restore --data-only --no-owner --disable-triggers -d "$NEW_DATABASE_URL" data.dump

Leaving Zeabur before 1 December

DateWhat happens
NowNo new dedicated servers or domains can be bought; balance refunds can be requested through Zeabur Support
1 December 2026Shared-cluster services are paused (you can restart them by hand to back up data); dedicated server renewals stop
1 to 31 DecemberEach dedicated server goes offline on its own expiry date
31 December 2026Final shutdown: all shared clusters stop
Not announced yetThe data deletion date and final export deadline

Zeabur's migration guide lists what disappears with it. These are the ones that break things:

  • Images in Zeabur's registry. After shutdown, pulls fail and services will not restart. Make sure every service builds from a Dockerfile or the new platform's builder.
  • *.zeabur.app domains and Zeabur-issued HTTPS certificates. Move to your own domain; domains bought on Zeabur can be transferred out at zeabur.com/domains.
  • .zeabur.internal hostnames and injected variables. Services that reached each other through these need the new platform's addresses, and injected values (database hosts, ports) must be copied as real values.
  • Daily database backups. Export your data and set up your own backups.
  1. 1
    Rotate first (the sections above), so the values you set on the new platform are new ones.
  2. 2
    Inventory each service: source, domains, data size, variables. Export the project structure and the variables:
    Terminal
    # Save the project's structure (services, images, ports) as template YAML
    npx zeabur@latest project export --name my-project > zeabur-project.yaml
  3. 3
    Copy databases using the audit and data-only steps above. To reach a Zeabur database from your machine:
    Terminal
    # Reach a Zeabur database from your machine, then run pg_dump against it
    npx zeabur@latest service port-forward --name postgresql --enable
    # ... dump ...
    npx zeabur@latest service port-forward --name postgresql --disable
  4. 4
    Dry run on the new platform: deploy, load a copy of the data, test, and restore a backup once.
  5. 5
    Cut over: stop writes on Zeabur, copy the data one last time, switch DNS, and keep the old services paused, not deleted, until the new ones have run cleanly for a few days.
  6. 6
    Claim what you are owed: balance refunds and incident compensation are separate requests through Zeabur Support.

Where to go is a judgement call. Zeabur's guide suggests comparing Railway, Coolify, k3s and Docker Compose; note that Railway had its own incident a week later. Whatever you pick, look for secrets that can be marked write-only (Railway calls them sealed variables), audit logs, private networking for databases, and a way to revoke every token at once. Never paste your Zeabur token into a chat or an AI assistant: Zeabur warns it grants access to your whole account.

What to watch afterwards

  • Bills and usage at every provider whose key was stored on the platform, for at least a month.
  • Audit logs: GitHub, your cloud account (CloudTrail), Stripe request logs, registry activity.
  • Phishing. PingCAP warns customers to expect targeted phishing, and the Zeabur data included the email addresses and phone numbers of 321,810 accounts. Be suspicious of urgent requests to change billing details or share a verification code.
  • Zeabur Dedicated Servers: if you cannot rebuild yet, remove unknown keys from /root/.ssh/authorized_keys (including any ending in @zeabur-pentest), check crontab, systemd services and the google-sudoers group, change the root password and turn off SSH password login, as Zeabur's report lists.

Common questions

Was my Zeabur data leaked?+
Zeabur treats the environment variables of every service on its shared clusters as leaked, and it read 3,218,965 variable records and 321,810 account records from its platform database on 15 October 2026. If you deployed on a shared cluster, assume your variables are known and rotate them.
When does Zeabur shut down?+
Shared-cluster services are paused on 1 December 2026, dedicated servers go offline on their expiry dates during December, and the final shutdown is 31 December 2026. The data deletion date has not been announced.
Do I need to rotate my Railway variables?+
Railway recommends rotating sensitive variables stored before 21 October 2026. At most 1.85% of workspaces were affected, but Railway cannot tell which variables were decrypted, so rotating is the safe choice.
Is it enough to change the variable in the dashboard?+
No. Changing the stored value does not stop the old one from working. Revoke the old key or password where it was issued (Stripe, AWS, OpenAI, your database), create a new one, set it, and redeploy.
What should TiDB Cloud users do?+
Organisation owners should replace management API keys under Organization Settings > API Keys, revoke any they no longer need, and watch for phishing. Treat anything typed into the SQL editor as exposed. PingCAP says it found no evidence that data stored in TiDB was affected.

Want help rotating or migrating?

I rotate credentials across Stripe, AWS, AI providers and databases, move apps and databases to a new host, and verify that nothing still uses the old values. See Node.js, Python and Next.js bug fixing or full-stack web app development.

Sources

Next steps

Keep reading

Need a hand with this?

Tell me what you are working on. Free scoping call, fixed quote within 48 hours.

Book a call

← All guides