All posts
VibeZero Team9 min read

Hardcoded credentials, explained

A credential written into source code cannot be rotated, scoped, or revoked without a release. Why AI generators produce them by default and how to find yours.

  • security
  • vibe coding
  • engineering

A hardcoded credential is not a weak credential. It is usually a perfectly strong one, generated by a provider, high entropy, impossible to guess. The problem is not the string. The problem is where it lives.

A credential in source code has lost the three properties that make a credential manageable. You cannot rotate it without shipping a release. You cannot scope it to one environment, because the code runs in all of them. And you cannot revoke it from the copies that already exist, because it is in every clone, every fork, every build artifact and every developer’s laptop that ever checked the project out.

CWE-798 names the class in one line: “The product contains hard-coded credentials, such as a password or cryptographic key.” What that line hides is that two very different bugs share the number.

Two bugs, one CWE

MITRE splits it into variants, and the split is the most useful thing on the page because the two halves have different attackers and different fixes.

Inbound authentication. The product checks incoming credentials against a set baked into itself: “a default administration account is created, and a simple password is hard-coded into the product and associated with that account.” This is the shape that ships in appliances and self-hosted software, and it is the one covered in default credentials explained. The attacker reads the vendor’s documentation, or the vendor’s source, and logs in.

Outbound authentication. The product “connects to another system or component, and it contains hard-coded credentials for connecting to that component.” MITRE adds the part that matters for anything with a frontend: this “applies to front-end systems that authenticate with a back-end service.”

Almost everything an AI builder generates is the outbound variant. Nobody is writing a login form that compares against a constant. They are writing a Stripe call, a Supabase client, an OpenAI request, and the key for it is sitting three lines above as a string literal because that is the shortest path from prompt to working feature.

The outbound variant also has the worse economics. An inbound default password gets you into one deployment. An outbound key gets the attacker into a provider, under your account, with your billing attached, from anywhere.

Why generated code produces them by default

This is worth being precise about, because the usual explanation (“the model is careless”) is wrong and leads to the wrong fix.

A model writing code optimizes for the code running. A literal always runs. An environment variable runs only if something outside the file it is writing has been configured correctly, and the model cannot see whether that happened. Given a choice between an artifact that works when you paste it and an artifact that throws undefined until you go set something up, the model produces the one that works. So do most humans under the same incentive, which is why this class predates AI by thirty years.

This is why the class shows up in generated projects at a rate that has nothing to do with the developer’s care. It is the same mechanism whether the assistant is writing a third-party call directly into a Lovable frontend or wiring a Supabase client into a Bolt project: the artifact that runs is the artifact that ships.

The second reason is subtler and specific to conversational development. The credential frequently enters the codebase because you pasted it into the chat. You wanted the integration to work, the assistant asked for a key, you gave it one, and the assistant did the natural thing with a value it was handed: it put it in the file. At that point the key is in the repository, in the conversation history, and in whatever telemetry sits behind the assistant.

Three specimens, and a shape

Real advisories are useful here because they show the class landing on serious software, not on beginners.

A credential nobody was meant to know. CVE-2026-5189, rated 9.2, is hardcoded credentials in Sonatype Nexus Repository Manager 3.0.0 through 3.70.5. With one non-default listener enabled, an unauthenticated network attacker gets “unauthorized read/write access to the internal database and execute arbitrary OS commands as the Nexus process user.” A single constant, in an artifact repository, which is to say in the thing that feeds every build in the organization.

A credential that survives the setup wizard. CVE-2026-44825, rated 8.1, is Apache Solr’s own Basic Authentication setup tool creating template users (superadmin, admin, search, index) with publicly known credentials that persist next to the accounts you actually configured. The tool whose entire purpose was to turn authentication on left four ways past it. This one sits exactly on the border between the two CWE variants, which is why the border is not a useful place to spend time: the reader’s job is to find the constant, not to classify it.

The fallback. The shape that produces most of these in application code is not an assignment, it is a default:

const secret = process.env.API_SECRET || "dev-secret-change-me";

Every word of that string is an admission and none of it is a control. The instruction to change it is inside the credential, and shipping it as a fallback guarantees the instruction is skipped by exactly the deployments least likely to notice. This is the entire content of the 9router advisory discussed in JWT weak secrets, and it is the single pattern most worth grepping your own code for tonight.

The fix is a boundary, not a rename

Moving a literal into an environment variable is necessary and is not sufficient, because it addresses where the string is written and not who can read it. Three things have to be true together.

The value is supplied at runtime, and its absence is fatal.

// Read once, at startup. No fallback, no `||`, no "sensible default".
const required = ["DATABASE_URL", "STRIPE_SECRET_KEY", "SUPABASE_SECRET_KEY"];
const missing = required.filter((name) => !process.env[name]);
if (missing.length) {
  throw new Error(`missing required secrets: ${missing.join(", ")}`);
}

Crashing is the feature. A process that starts with a missing secret will be debugged at some point by somebody who inlines the value “just to check”, and that commit is how most of these arrive.

The variable has no client-exposed prefix. VITE_ and NEXT_PUBLIC_ are not naming conventions, they are instructions to the bundler to publish the value. A secret behind one of those prefixes is hardcoded again, in the build output, with an extra step. That mechanism has its own post.

The file holding the values is not committed, and never was. The .gitignore entries for that, and the .env.example with names and no values that stops the next contributor from inventing their own arrangement, are in .env file exposure, along with the second route that has nothing to do with git: your own web server publishing the file. Note that ignoring it protects the future only. If the value was ever committed it is still in the history, which is a separate problem with a separate fix, covered in secrets in git history.

And when you do find one, the order is fixed: rotate at the provider first, because that is the only step that closes anything, then read the provider’s audit log for the exposure window, then clean up. API key exposure explained walks the full sequence and the reasons the intuitive order is backwards.

What we detect, and the one thing the CWE cannot tell you

Two engines in our secrets layer look for this, and they overlap on purpose.

Gitleaks (app/engines/gitleaks.py) runs rule-driven pattern matching over the checked-out repository and reports each hit at HIGH with CWE-798 attached. TruffleHog (app/engines/trufflehog.py) runs its own detector set over the same tree. It has a verification mode that calls the issuing provider to prove a key is live, and we run it with --no-verification deliberately: AGENTS.md forbids testing credentials against third parties on your behalf, so we tell you a credential is present and shaped like a live one, and we do not go find out. That is a real limit and it is the right one, because the alternative is our scanner authenticating to your Stripe account without asking.

Neither engine ever stores the secret. Both keep the first four characters so you can locate the line, and redact the rest before the value reaches a finding, a raw payload, or a log.

The interesting design consequence is about grouping. Every secret finding from both engines carries the same CWE-798, so the CWE says nothing: grouping our fix tasks by it would merge an AWS key, a Stripe key and a private key into one task called “hardcoded credentials”, whose remediation would be three unrelated rotations at three unrelated providers. That is why our grouping ladder puts the engine’s own rule_id ahead of the CWE. The rule names the provider, which is what decides where you go to rotate. The CWE names the whole category, and a category is not a task.

OpenGrep, our SAST engine, catches the shape rather than the string: the ?? "dev-secret" and || "changeme" fallbacks, where a configuration lookup has a constant behind it. It runs against a rules bundle baked into the worker image and scoped to your detected languages, and its WARNING findings normalize to MEDIUM in our schema.

What none of them can see is a credential that only ever exists as an environment variable in a hosting dashboard. There is nothing in the code to find, correctly, and the question moves to who on your team can read your deployment configuration. For the copy that reaches your users, our free exposed API key scanner reads the JavaScript your deployed site actually serves, which is the one place a hardcoded credential stops being a code smell and becomes a published fact.

The bug and the fix live in different systems

Almost every other vulnerability class is repaired where it was introduced. A missing authorization check is fixed in the handler that lacked it. An injection is fixed at the query that built the string. You change the code, the code is the thing that was wrong, and the change is the fix.

This one splits. The defect is a line in your repository, and the repair is a button in somebody else’s dashboard, and doing only the half you can see leaves a working credential in the world. A pull request that removes the constant, reviewed and merged and deployed on a Friday, changes nothing whatsoever about who can spend your OpenAI budget on Saturday.

That asymmetry is the entire reason the order runs rotate, audit, then edit, and why our release decision treats a credential that is still valid as the open item rather than the commit that carried it. The edit is the tidy part. The rotation is the fix.

ShareXLinkedIn