JWT weak secrets, explained
An HS256 token contains everything an attacker needs to test a billion guesses offline. Why signing secrets get cracked, published, or shipped as defaults.
- security
- vibe coding
- engineering
Most credential attacks need your server. This one does not.
When a token is signed with HS256, the signature is
HMAC-SHA256(secret, header + "." + payload). An attacker who holds one token
holds the input and the output of that function. They can guess a secret,
compute the HMAC, compare it to the signature, and know immediately whether the
guess was right, all on their own hardware, with your application unaware that
anything is happening.
No login attempts. No rate limit to trip. No entry in any log you own. The only thing standing between a guess and your signing key is how many guesses the attacker can afford, and the attacker chose the hardware.
Once a secret falls, everything downstream of the signature falls with it. The attacker does not steal a session, they issue sessions: any user id, any role, any expiry, indistinguishable from tokens your own server minted because they are tokens your own server would have minted.
The economics of an offline attack
Cracking JWTs is a supported feature of standard tooling, not a research project. Hashcat has carried mode 16500, “JWT (JSON Web Token)”, for years, with an example hash in its documentation. You feed it a token and a wordlist.
That means the difficulty of the attack is set entirely by the entropy of the
secret, and the specifications are unusually blunt about it.
RFC 7518 section 3.2 states that “a key
of the same size as the hash output (for instance, 256 bits for HS256) or
larger MUST be used with this algorithm.”
RFC 8725 section 3.5 removes the
remaining ambiguity: “Human-memorizable passwords MUST NOT be directly used as
the key to a keyed-MAC algorithm such as HS256.” Section 2.2 explains why, in
the form of the threat: an application that supplies “a weak symmetric key with
insufficient entropy (such as a human-memorable password)” leaves tokens
“vulnerable to offline brute-force or dictionary attacks.”
A 256-bit random key is not crackable by anyone, at any budget, with any
hardware, and it costs one command to generate. A memorable phrase is a
dictionary entry with punctuation. There is no middle ground where a
better-chosen word buys you meaningful time, which is the part that gets
misjudged: people trade secret for myapp-jwt-key-2026 and feel that they
improved something. Against a wordlist with rules applied, they did not.
In practice the secret is rarely guessed. It is published
The brute force above is the theory of the vulnerability. The field reports are usually simpler and worse: nobody had to crack anything, because the secret was knowable.
A default that survives deployment.
CVE-2026-49352, rated 9.8,
is the pure specimen. The 9router package fell back to a hardcoded string when
the JWT_SECRET environment variable was unset, and the string was
9router-default-secret-change-me. Anyone who read the source, which is
published on npm, could sign a token as any user of any deployment that had not
set the variable, set it as a cookie, and hold the dashboard and the API. The
instruction to change it was inside the credential, and shipping it as a fallback
guaranteed that the instruction would be skipped by exactly the deployments least
able to notice.
A default in the deployment file rather than the code.
CVE-2025-13877 in NocoBase
is the same failure one layer out: the official Docker Compose configuration
carried a public default JWT key, so a deployment that ran the documented quick
start was forgeable by anyone who had read the documented quick start. Construct
a token with a known userId, usually the administrator, and authentication is
bypassed. The fix is the one worth copying: the patched versions refuse to start
on a weak or defaulted secret, rather than warning about it. This is
default credentials with a signature
algorithm bolted on, and it inherits that class’s central property, which is that
the attacker does not need your deployment to be careless, only to be typical.
The repository. A secret that was hardcoded, then moved to an environment variable in a later commit, is still in the history, in every clone and every fork. The mechanism, and why deleting the commit does not help, is covered in API key exposure explained.
The bundle. A signing secret that reaches the browser is not a signing secret
any more. That happens whenever a verification helper written for the server gets
imported by a client component, or whenever the secret is given a VITE_ or
NEXT_PUBLIC_ prefix, which is not a naming convention but an instruction to
publish.
On Supabase, the JWT secret is the service role key
This one deserves its own section, because in a generated app the JWT secret is usually not protecting a login form. It is protecting the database.
Supabase issues access tokens whose claims are read directly by your Row Level
Security policies. The role claim decides which Postgres role the request runs
as, and sub is what auth.uid() returns. Supabase’s own
JWT documentation states the
consequence plainly: “A shared secret that is in the hands of a malicious actor
can be used to impersonate your users, give them access to privileged actions or
data.”
Work through what that means with the legacy symmetric setup. An attacker with the project’s JWT secret writes this payload, signs it, and sends it as a bearer token:
{ "iss": "supabase", "ref": "abcdefghijklmnopqrst", "role": "service_role", "exp": 2000000000 }
They have now minted the credential from
the service role key post without
that key ever leaking. Every policy described in
RLS misconfiguration explained is not
weakened, it is not evaluated, because the role they claimed carries BYPASSRLS.
A leaked signing secret and a leaked service role key are the same incident
reached by different routes.
Supabase’s structural answer is to stop using a shared secret at all. The signing keys guide lists four options and labels HS256 “Not recommended for production applications”, recommending the P-256 elliptic curve instead. The reason is not that HMAC is weak, it is that a symmetric secret has to exist in every place that verifies a token, so its blast radius grows with your architecture, while an asymmetric public key can be handed to anything without granting the ability to sign. The operational difference is stark in that guide: rotating the legacy secret “requires changing of your application’s backend components” and “currently active users get immediately signed out”, whereas rotating an asymmetric key means “no users get signed out.”
If you are on a project old enough to have a shared JWT secret, migrating is the rare security change that also removes an outage from your future.
Holding a secret that is worth having
The generation is one line. The handling is where projects lose it.
# 32 bytes (256 bits) from the OS CSPRNG, base64 for transport.
openssl rand -base64 32
// Fail closed at startup. A missing secret must never become a weak default.
const raw = process.env.JWT_SECRET;
if (!raw) throw new Error("JWT_SECRET is not set");
// Decode what the command above produced and require the full 256 bits.
const JWT_SECRET = Buffer.from(raw, "base64");
if (JWT_SECRET.length < 32) {
throw new Error("JWT_SECRET decodes to fewer than 256 bits");
}
Be clear about what that check does and does not prove. It measures size, not
randomness, and it cannot tell the difference between 32 bytes from
openssl rand and 32 bytes somebody typed. The entropy came from the command,
not from the guard. The guard is there to catch the empty string, the truncated
paste and the placeholder, which is what actually happens.
Four rules around it, each of which is a real incident somewhere:
- A different secret per environment. Staging is where credentials go to be shared, screenshotted and pasted into a chat. A staging secret that also signs production tokens turns a low-stakes environment into the production key store.
- Never a fallback. The
||inprocess.env.JWT_SECRET || "dev-secret"is the entire content of CVE-2026-49352. Crash instead, exactly as the NocoBase patch chose to. - Rotation has to be survivable before you need it. Changing an HMAC secret invalidates every live token at once, which is why it never happens during an incident when it most needs to. Support two valid keys at verification time, one at signing time, and rotation stops being a decision about downtime.
- The payload is not private. Nothing in it is secret, ever. A JWT is signed, not encrypted, and anyone holding the token reads it. Emails, internal ids and role names in a token are disclosed by design.
What we detect, and what we cannot
Our SAST engine, OpenGrep, runs a rules bundle baked into the worker image and
scoped to your detected languages. hardcoded-jwt-secret is the relevant rule
for this post: it flags a JWT secret written as a literal in JavaScript or
TypeScript, carries CWE-798 for hardcoded credentials, and ships at WARNING,
which our engine normalises to MEDIUM. Its sibling jwt-none-alg belongs to
the alg=none post.
Gitleaks and TruffleHog then run over the repository, which is the part that matters more here, because the interesting copy of a secret is usually not in the working tree. It is in the commit from March that moved the constant into an environment variable and left the constant behind.
The two limits are worth stating, because a scanner that quietly misses something
is worse than one that says what it does not do. A secret that only ever exists
as an environment variable at runtime is invisible to static analysis, correctly:
there is nothing in the code to find, and the question moves to who can read your
deployment configuration. And no scanner can judge the entropy of a secret it
cannot see, so JWT_SECRET=password123 in a hosting dashboard is a finding
nobody will report to you.
A third limit is worth stating even more plainly, because it is counter-intuitive. Our free scanner reads the JavaScript a deployed site actually serves and matches credentials that have a recognisable shape: the Supabase key tiers, AWS, Stripe, Clerk, GitHub, private keys, connection strings. A bare HMAC signing secret has no shape. It is 44 random characters, indistinguishable from a build hash or a cache key, so no bundle scanner is going to tell you that the one in your JavaScript signs your tokens. This is the credential on this page you have to find by knowing where you put it.
What a scan does find is the vendor-shaped consequence, which on a generated app is usually the same incident by the route described above: the Supabase key tiers with the Supabase key checker, and the wider sweep of recognisable credentials with the exposed API key scanner.
There is one more that is worth knowing about, because it catches the outcome
of this post rather than its cause. Our runtime engine decodes the JWTs it finds
in a deployed page and reports jwt-privileged-claim, at critical severity, when
one of them carries service_role, admin, superuser, root or owner. It
does not care how that token came to exist. A privileged token that was minted
legitimately and then shipped to the browser and one forged with a cracked secret
look identical from the outside, which is exactly the point: by the time either is
in the page, the distinction has stopped mattering to whoever finds it.
The reason this ranks above most findings
A weak signing secret is not a way into one account. It is the authority to create accounts that were never registered, with roles that were never granted, that pass every check your application performs because they are cryptographically valid.
That is why it blocks a release in our decision model rather than joining a severity-labelled backlog, and why the remediation order starts with rotation even though rotation is the disruptive step. Everything else you might do, tightening claims, shortening expiry, auditing routes, is applied to a signature an attacker can already produce.