Rotating Secrets After Railway's Env-Var Breach: A PaaS Blast-Radius Audit

Rotating Secrets After Railway's Env-Var Breach: A PaaS Blast-Radius Audit

pr0h0•
securitysecrets-managementrailwaypaasdevops
AI Usage (99%)

Introduction: The Secret Was Never Yours Alone

On 23 October 2026, a Rescana report described a security breach at Railway in which customer environment variables were exposed, and developers were told to rotate their secrets. The obvious response is to open the dashboard, hit rotate, and move on. That response is mostly wrong, and it is wrong in a way that leaves you with the same exposure plus a broken deploy at 2 a.m. What follows is a PaaS blast-radius audit in the order that actually closes the exposure: inventory every copy of the secret, rotate in dependency order behind an overlap window, then prove the old values are dead.

The credential in your dashboard is a copy living in someone else's failure domain

When you set DATABASE_URL in a PaaS dashboard, you did not create a secret. You created a copy of a secret that already exists in Postgres, in your CI runner, in a teammate's .env, and possibly in a build log. The PaaS is one custodian among several, and it just had a bad week. Your security posture is bounded by the weakest custodian, not the one with the nicest UI.

The position: rotate in dependency order and prove the old values are dead

"Rotate everything tonight" is not a security plan; it is a change-management incident. The order matters because some secrets are consumed by upstream stores, some by running services, and some invalidate state the moment they change. The correct sequence is: inventory, rotate with overlap, then prove the old credential no longer works. Panic skips step three, and step three is the only step that actually closes the exposure.

Scope note

The only source available for this post is the Rescana report summary dated 2026-10-23. Everything asserted as fact about the incident traces to that summary. Gaps are labelled as gaps. No CVE, version range, affected-customer count, or official Railway statement appears here, because none was in hand.

Three artifacts you should leave with

A secret inventory (names, consumers, owners), an ordered rotation ledger (mechanism, overlap window, revocation path), and a dated verification record showing what you tested and what the result was. The third one is what you will reach for during the next incident.

What the Rescana Report Says, and What It Does Not

Confirmed by the source

The Rescana report, published 2026-10-23, states that Railway suffered a security breach that exposed customer environment variables and that developers were urged to rotate secrets. That is the full extent of what this post treats as established.

Unknown from this source alone

QuestionStatus
Which variable classes were exposed (app env vars vs. platform tokens)Unknown
Whether values were read, exfiltrated wholesale, or bothUnknown
Whether Railway revoked platform API tokens or sessionsUnknown
Exact disclosure timeline and detection windowUnknown
Number of affected projects or customersUnknown

Deliberately not invented

No CVE identifier, no affected-version range, no customer count, no quoted Railway spokesperson. If a source does not state it, a blog post should not manufacture it to sound authoritative. The gaps are themselves operationally meaningful, which is the next point.

Why the gap changes the rotation order

If the exposure was limited to application environment variables, your blast radius is whatever those variables unlock: databases, third-party APIs, signing keys. If platform API tokens or build artifacts were also in scope, the attacker could read variables and re-read them after your rotation, plus push a deploy. The first case is a credential rotation problem. The second is an account-takeover problem: resecure the platform account before touching application secrets, or you are rotating into a channel the attacker still controls.

Where a PaaS-Held Secret Actually Lives, and Who Can Read It

Enumerate the copies, not the one you set

  • The runtime process environment of every service and every replica
  • Build and deploy logs that echo env at startup (yes, this happens)
  • Preview and PR-branch deployments that inherit variables from the parent environment
  • The platform's own API and database, readable by anyone with project access
  • A CLI session that pulled .env down to a laptop
  • Support or debug dumps, including stack traces that stringify config

The corollary: this audit is also an access review

Anyone with read access to your project's variables has precisely the access an attacker just obtained. If you would not hand that person the production database password in chat, they should not have the role that reveals it. The rotation exercise is a good excuse to list who holds that role and why.

Secret classes, ranked by how much a leak hurts

Secret classRealistic impact if leakedRotation difficultyShort-lived credentials feasible?
App database passwordFull read/write on app dataLow–mediumPartly — IAM/STS auth to the DB
Service-to-service API keyLateral movement inside your own estateMediumYes — mTLS or OIDC
JWT signing keyForge a token for any userHigh — invalidates stateNo, but kid overlap mitigates
Webhook HMAC secretForge inbound eventsMediumPartly — accept two secrets during overlap
Cloud static access keyWhatever the IAM policy allowsMediumYes — OIDC federation
PaaS platform API tokenRead every variable you just rotatedMediumPrefer scoped, short session tokens

Step 1: Inventory Every Copy of the Secret Before You Rotate

Why blind rotation fails

Rotate a credential you have not traced, and you will miss a consumer: a cron worker, a Lambda outside the platform, an analytics job someone set up in 2023. It breaks at 2 a.m., the on-call engineer rolls back to the old value to restore service, and now the leaked credential is live again. The inventory exists to prevent that rollback.

Non-destructive enumeration: names only

The public GraphQL endpoint returns a name→value map. Filter to keys before anything reaches your scrollback or CI log.

## Names only. Values never leave the pipe.
curl -s https://backboard.railway.app/graphql/v2 \
  -H "Authorization: Bearer $RAILWAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query":"query($p:String!,$e:String!,$s:String!){variables(projectId:$p,environmentId:$e,serviceId:$s)}","variables":{"p":"PROJECT_ID","e":"ENV_ID","s":"SERVICE_ID"}}' \
  | jq -r '.data.variables | keys[]'

Expected output shape (names illustrative):

DATABASE_URL
REDIS_URL
STRIPE_SECRET_KEY
JWT_SIGNING_KEY_A
WEBHOOK_HMAC_SECRET

Two honesty notes: I did not run this against a live project while writing, so verify the field names against your CLI and API version; and the keys[] filter is the point — whatever the API returns, only names survive.

The ledger

Secret nameIssuerConsumersRotation mechanismRevocation mechanismOwnerClass
DATABASE_URLManaged Postgresapi, worker, migration jobALTER ROLE ... PASSWORDALTER ROLE ... NOLOGIN on old roledatarotatable-with-overlap
STRIPE_SECRET_KEYStripeapi, billing-cronProvider roll, two active keysRevoke old key in dashboardpaymentsrotatable-with-overlap
JWT_SIGNING_KEY_AYouapi (sign + verify)Publish new kidRemove old key from verify setplatformstatic

Classify honestly

Mark each row static, rotatable-with-overlap, or cannot-rotate-cheaply. The last bucket — partner-issued credentials that need a support ticket, keys baked into shipped firmware, keys protecting already-encrypted data — is where real blast-radius work belongs. Everything in the middle bucket is just a Tuesday.

Step 2: Rotate in Dependency Order, With an Overlap Window

The two-phase pattern

Issue the new credential while the old one is still valid → deploy every consumer → confirm the new credential is in use → revoke the old one → confirm you get 401s only where you expect them.

Ordering heuristics

Upstream data stores first (a dead database password breaks everything downstream, so fix it while you still have a working fallback). Service-to-service credentials second. Signing keys last, because they invalidate state rather than access.

Worked example

Postgres password. Create a second role with identical grants, deploy with the new connection string, watch connection counts move from the old role to the new one, then ALTER ROLE old NOLOGIN. Overlap window: one deploy plus a safety margin. Failure symptom if you skip the overlap: FATAL: password authentication failed across all replicas simultaneously.

Service-to-service API key. Issue key B, deploy consumers with both keys configured, confirm logs show B in use, revoke A. Overlap: one deploy. Failure symptom: 401s between your own services that look like a network partition.

JWT signing key. Do not swap the secret. Publish a second key with a new kid, sign with the newest, verify against the set, and let old tokens expire naturally.

// Verify against a set keyed by kid; sign with the newest.
const keys = new Map([
  ["2026-10-a", process.env.JWT_KEY_2026_10_A],
  ["2026-10-b", process.env.JWT_KEY_2026_10_B], // newest
]);

export function verifyToken(token) {
  const { header } = jwt.decode(token, { complete: true });
  const secret = keys.get(header.kid);
  if (!secret) throw new Error(`unknown kid: ${header.kid}`);
  return jwt.verify(token, secret, { algorithms: ["HS256"] });
}

Overlap window: your maximum token lifetime, plus clock skew. Remove the old key only after that window closes, then redeploy.

⚠️

Revoking a signing key or a webhook HMAC secret without an overlap logs every user out or silently drops inbound webhooks. The second one is worse: webhooks that fail HMAC verification are often dropped with a 2xx, so you get an outage that looks like a quiet week.

Step 3: Prove the Old Credentials Are Dead

Direct checks

Take the old value and use it. Expect rejection, not ambiguous failure.

CheckExpected resultIf it succeeds
Old DB password against the databasepassword authentication failedRotation never applied
Old API key against a harmless authenticated endpoint401Key not revoked at the issuer
Old JWT signing key minting a tokenRejected as unknown kidOld key still in the verify set
Old webhook secret signing a test event403 from your endpointDual-secret window still open

An 401/403 is a pass. A 200 means you have a live copy of the leaked credential in production and should treat the incident as ongoing. A connection timeout is not a pass — it is an unverified result, and unverified is where leaks hide.

Indirect checks

Watch auth-failure rate, webhook delivery failures, and cron job alerts for 24–48 hours after revocation. A small, stable bump in 401s from a decommissioned client is expected; a rising line is a consumer you missed.

Hunt the stragglers

  • Search deployed environments for the revoked variable name in every service, including ones you forgot existed.
  • Grep CI configs and infrastructure-as-code for the old value's prefix (sk_live_, postgres://, and so on) — never for the full value.
  • List preview and fork environments; they inherit variables and outlive the branch that created them.
  • Ask the issuer for last-used telemetry. Cloud providers and payment APIs expose "last used" timestamps on keys, which is the strongest signal available that nothing is still holding the old value.

Write it down

A dated record of what changed, by whom, which verification ran, and what it returned. During the next incident, this is the artifact that tells you whether the credential in front of you is pre- or post-rotation.

Shrinking the Blast Radius So This Is Boring Next Time

Prefer short-lived credentials

OIDC workload identity federation replaces a static cloud key with a token minted per workload and expiring in minutes. A dump of your environment variables then yields credentials with a shelf life measured in minutes, not quarters.

Split secrets by blast radius

A preview deployment should never be able to read the production superuser connection string. Separate variable sets by environment and by privilege, so a read-only analytics key and a production write key are never in the same bundle.

Separate the platform trust boundary

Someone who can read production variables can exfiltrate them. Require SSO, assign the minimum role that lets people do their job, and make sure the platform account itself has an audit trail you can read.

Keep the ledger offline

If the platform is the thing that was compromised, a recovery plan that lives only inside the platform is not a recovery plan.

What Would Change This Advice

If platform tokens or build artifacts were exposed

Move platform-account credential rotation to the front: revoke sessions and API tokens, re-enable SSO enforcement, then audit deploy history for changes you did not make. Application secret rotation comes after, because otherwise the attacker can simply read the new values.

If exposure was limited to environment variables

The ordering above holds. The priority is verification, not panic: rotate in dependency order, prove the old values fail, and keep watching for 48 hours.

This branch is unresolved in the available source

The Rescana summary does not state the scope. What would confirm it: an official Railway statement or postmortem, a status-page incident entry, or a support communication to affected projects. Until one of those exists, treat the platform-token scenario as untested and plan for it, because the cost of planning is an hour and the cost of being wrong is a second breach.

Conclusion

Platform-held secrets are a shared failure domain. You cannot audit your way out of that, and faster incident response is not the fix — shorter-lived credentials, tighter scoping, and a written rotation ledger are. Those three things make the next PaaS breach a checklist instead of an all-nighter.

Today: build the ledger, then rotate your three highest-impact secrets in dependency order and verify each one with a direct check. If the old value still returns a 200, you are not done.

Further Reading

Rescana report on the Railway environment variable exposure

Primary source for this post: the Rescana report item, published 2026-10-23 (aggregator redirect).

Railway documentation on variables and environments

How values are scoped to services and environments, which determines what a preview deployment can read: docs.railway.com variables guide.

OIDC workload identity documentation

The mechanism for replacing static cloud keys with short-lived federated tokens: AWS IAM OIDC identity providers and Google Cloud workload identity federation.

NIST SP 800-57 and the OWASP Secrets Management Cheat Sheet

Key-lifecycle and rotation guidance beyond the emergency case: NIST SP 800-57 Part 1 Rev. 5 and the OWASP Secrets Management Cheat Sheet.

Share this post

More posts

Comments