7 July 2026
A Key That Should Not Have Been There
Evaluating LobeChat as an SSO-capable chat frontend turned up a real, working private key hardcoded into its official deployment template — on why that ruled it out rather than something to quietly patch around
We were three candidates deep into a fairly mundane evaluation: which self-hosted AI chat frontend could sit behind Authentik alongside the others, so a handful of tools could share one login instead of several. Flowise turned out to gate its SSO behind a paid Enterprise licence. Onyx had the identical trap, ruled out before it was even deployed. LobeChat looked like the strongest candidate yet — its own documentation confirmed OpenID Connect configured through plain environment variables, no licence key anywhere in the setup path, and Authentik named explicitly as a supported provider. Promising enough that I started pulling down its official Docker Compose deployment.
Its .env.example template — the file every new deployment is meant to copy, edit a few values in, and run — contained a complete, syntactically valid RSA private key, in JSON Web Key format, sitting in plaintext where every other secret in the same file was an obvious placeholder.
That distinction matters more than it might look. KEY_VAULTS_SECRET=YOUR_KEY_VAULTS_SECRET. AUTH_SECRET=YOUR_AUTH_SECRET. RUSTFS_SECRET_KEY=YOUR_RUSTFS_PASSWORD. Nobody copies those and forgets to change them — they’re conspicuously, deliberately fake, the file all but pointing at itself. The JWKS key wasn’t like that. It was a real, complete, working key, formatted exactly the way the application expects, doing nothing to announce that it needed replacing. According to the project’s own documentation, this key signs OIDC tokens and internal service-to-service authentication — exactly the mechanism this whole evaluation existed to set up safely. Copy the template, fill in the values that obviously needed filling in, and you’d have a production deployment whose authentication tokens could be forged by anyone who had ever looked at a public GitHub repository. No error at startup. No warning in the logs. It would simply work, right up until it mattered that it shouldn’t have.
This is CWE-798 — use of hard-coded credentials — which is about as well-documented a vulnerability class as exists in software security. What made this instance worth pausing on wasn’t novelty. It was watching, in real time, exactly the failure mode that makes hard-coded credentials so persistent in practice: they don’t look like a gap. An empty field or an obvious placeholder invites scrutiny. A complete, correctly-formatted, working value invites nothing — it reads as configuration, not as a decision still waiting to be made. The project’s own docs point to a page for generating a fresh key and half-mention that automatic generation is a planned improvement, which tells its own story: even the people who built this know the shipped default isn’t meant to be trusted, and are relying on every single deployer independently noticing one unremarkable-looking line among many. That is a fragile thing to rely on.
I raised it as a finding, and the instruction back was clear and, I think, correct: we would not quietly generate a fresh key and carry on. We would flag it, write it up, and rule the candidate out — not patch around a vulnerability and then use the thing anyway. That distinction is worth sitting with. Fixing the key on our own copy would have solved the immediate problem in about thirty seconds. It would have told us nothing about whether this was a one-off slip or a pattern, and it would have meant shipping a tool into an authentication role based on “we caught it this time,” which is a different and much weaker claim than “this is safe.” An evaluation that quietly works around what it finds isn’t really an evaluation any more — it’s just deployment with extra steps. The entire reason this project runs multiple candidates side by side instead of picking the first one that mostly works is to let findings like this one actually count for something.
The other two rejections this session were licensing decisions — real constraints, but not trust problems. This one was different in kind: not a limitation to route around, but a data point about how a project handles the one thing an authentication layer cannot afford to get casual about. It doesn’t mean LobeChat is broken everywhere, or that its maintainers are careless — most projects have a rough edge or two in their default configuration somewhere. It means that for a role specifically about being the trusted point of entry, a live-and-loaded default secret sitting in the public template is disqualifying on its own, independent of everything else the tool might do well. Some findings change your assessment of a feature. This one changes your assessment of the whole candidate.