Updating a non-auth field on a gateway erases its stored auth (`auth_value` → `{}`)

Open Beginner friendly
#6,446 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
78/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
python

Research direction

Start in mcpgateway/services/gateway_service.py at update_gateway, then compare the persistence branch with the final_auth_value logic around line 2825. Verify that an update omitting auth fields preserves the stored credential while still applying unrelated changes, and add regression coverage for the reported authheaders behavior.

Written by the indexing model from the issue text.

Description

SHOULD
🐞 Bug Summary

update_gateway treats an absent auth field as an explicit clear. Any update that changes some other field, and does not carry auth, wipes the gateway's stored credentials.

Reproduced on main (1.0.8) — see below. What you observe depends on whether the upstream tolerates a credential-less request:

  • Upstream accepts it → the update returns 200 and auth_value is silently replaced with {}. auth_type is left untouched, so the gateway still reports itself as authenticated while holding no credential.
  • Upstream rejects it → the post-update re-initialization goes out without the credential, gets a 401, and the update fails with 502; the transaction rolls back, so the credential survives but the legitimate update is impossible.

This affects every credential-bearing auth type (bearer, basic, authheaders), because the branch responsible runs whenever an update carries none of auth_token, auth_password, or auth_header_value.

The relevant code is in mcpgateway/services/gateway_service.py::update_gatewaymain L3005-L3033:

if hasattr(gateway_update, "auth_headers") and gateway_update.auth_headers:
    ...                                   # merges, and preserves masked entries
    gateway.auth_value = header_dict
elif settings.masked_auth_value not in (token, password, header_value):
    decoded_auth = decode_auth(gateway_update.auth_value) if gateway_update.auth_value else {}
    current_auth = getattr(gateway, "auth_value", {}) or {}
    if current_auth != decoded_auth:
        gateway.auth_value = decoded_auth      # <-- absent auth becomes {}

With auth omitted, token/password/header_value are all None, so masked_auth_value not in (None, None, None) is true and the elif runs; gateway_update.auth_value is also None, so decoded_auth is {} and the stored credential is replaced.


🧩 Affected Component
  • mcpgateway - API
  • mcpgateway - UI (admin panel)
  • mcpgateway.wrapper - stdio wrapper
  • Federation or Transports
  • CLI, Makefiles, or shell scripts
  • Container setup (Docker/Podman/Compose)
  • Other (explain below)

🔁 Steps to Reproduce

Run any MCP server that accepts a credential header as the upstream. The stub used below is a Streamable HTTP MCP server exposing one echo tool; REQUIRE_KEY controls whether it rejects requests lacking x-api-key.

Variant A — upstream requires the credential (:5200)

  1. Register the gateway:

    curl -sS -X POST "$GW/gateways" -H "$AUTH" -H 'Content-Type: application/json' -d '{
      "name": "repro",
      "url": "http://host.docker.internal:5200/mcp",
      "transport": "STREAMABLEHTTP",
      "auth_type": "authheaders",
      "auth_headers": [{"key": "X-Api-Key", "value": "a-real-credential"}],
      "passthrough_headers": ["X-Tenant-Id"]
    }'
    

    created: reachable=True enabled=True authType=authheaders, tools discovered: 1 -> ['repro-echo']

  2. Update one non-auth field, omitting every auth field — what a client that has only ever seen the masked credential must send:

    curl -sS -X PUT "$GW/gateways/$ID" -H "$AUTH" -H 'Content-Type: application/json' -d '{
      "name": "repro",
      "url": "http://host.docker.internal:5200/mcp",
      "transport": "STREAMABLEHTTP",
      "passthrough_headers": ["X-Tenant-Id", "X-Trace-Id"]
    }'
    

    PUT returned HTTP 502

  3. The upstream's request log for that PUT shows the re-initialization arriving without the credential:

    [upstream] 200 POST - credential present      <- registration
    [upstream] 200 POST - credential present
    [upstream] 401 POST - x-api-key=<absent>      <- the PUT's re-initialization
    [upstream] 200 POST - credential present      <- later health checks (rolled back value)
    

Variant B — upstream accepts a credential-less request (:5201)

Identical registration and update against the permissive upstream, reading auth_value straight out of the database on either side:

=== 2. Stored credential BEFORE the update ===
  auth_type='authheaders'  reachable=1
  auth_value='{"X-Api-Key": "a-real-credential"}'

=== 3. Update ONE non-auth field, omitting every auth field ===
  PUT returned HTTP 200

=== 4. Stored credential AFTER the update ===
  auth_type='authheaders'  reachable=1
  auth_value='{}'

The update succeeds, reports nothing unusual, and the credential is gone. auth_type still reads authheaders and reachable is still 1, so nothing in the API surface indicates the gateway can no longer authenticate — it will keep working until the upstream starts enforcing, then fail its health checks and take all of its tools offline.

(Credential value above is a dummy from a throwaway container.)


🤔 Expected Behavior

Omitting the auth fields should leave the stored credential unchanged, and updating an unrelated field should not require re-sending credentials.

This is already the convention elsewhere in the same function. When computing the value for its uniqueness check a few hundred lines earlier, it explicitly preserves the existing credential if the update carries none — main L2825:

final_auth_value = decoded_auth_value if gateway_update.auth_value is not None else (
    decode_auth(gateway.auth_value) if isinstance(gateway.auth_value, str) else gateway.auth_value
)

So within a single request the service preserves the credential for its duplicate check and then discards it when persisting. Every other optional field on the update (name, url, visibility, oauth_config, …) follows the same is not None → preserve convention; auth is the exception.

The existing guard looks intended to express "if the client echoed the masked placeholder back, leave auth alone", which it does correctly — it just does not distinguish absent from empty.


📓 Logs / Error Output

The update itself logs nothing unusual in the silent case; it returns 200.

In Variant A, the client sees the failed re-initialization:

Failed to initialize gateway at http://host.docker.internal:5200/mcp:
Client error '401 Unauthorized' for url 'http://host.docker.internal:5200/mcp'

Downstream, once a gateway has been wiped and its upstream enforces auth, tool calls surface as:

Tool '<gateway>-<tool>' exists but is currently offline. Please verify if it is running.

⚠️ No real credentials appear above; a-real-credential is a dummy value from a disposable test container.


🧠 Environment Info
Key Value
Version or commit main@cca5f97 — reported as 1.0.8 by /version
Runtime Python 3.12.13, uvicorn (single worker)
Platform / OS Linux (container)
Container Dockermain source mounted over /app/mcpgateway in ghcr.io/ibm/mcp-context-forge:v1.0.5 for its dependency set

Relevant settings: ENABLE_HEADER_PASSTHROUGH=true, SSRF_PROTECTION_ENABLED=false, HEALTH_CHECK_INTERVAL=10, UNHEALTHY_THRESHOLD=2, SQLite backend.

Note on the setup: I ran main's source with v1.0.5's installed dependencies rather than building the image, so a dependency delta between the two is not accounted for. /version reports 1.0.8 and main's newer startup validation (the jwt_secret_key ≥32 char and auth_encryption_secret placeholder checks) fires, so the executing code is main. Single uvicorn worker because the image's gunicorn.config.py hardcodes workers = 2, and two workers race on the SQLite file at startup.


🧩 Additional Context

Why omission is the normal case rather than an edge case. Because the API masks auth values on read, a client that does the obvious GET → change one field → PUT round-trip cannot send the real credential back — it has never seen it. Its options are to omit auth (destroys it) or to transmit the literal mask string (preserves it). The safe path is the non-obvious one, so any client that manages gateways declaratively is exposed by default.

Real-world impact. This took down a live gateway for us on 2026-08-27, on v1.0.4, where we saw the silent-loss variant: an automated apply changed only passthrough_headers, the update returned success, and the gateway's auth header was erased. Its health checks then failed, it was marked unreachable, and all of its tools became uncallable until the credential was re-sent by hand. Diagnosis was slow precisely because the update reported success, auth_type still showed the gateway as authenticated, and the user-facing error pointed at the upstream server, which was healthy the whole time.

Suggested fix — treat absent auth as "unchanged", matching final_auth_value above and the rest of the update model:

elif settings.masked_auth_value not in (token, password, header_value):
    auth_provided = gateway_update.auth_value is not None or any(
        v is not None for v in (token, password, header_value)
    )
    if auth_provided:
        decoded_auth = decode_auth(gateway_update.auth_value) if gateway_update.auth_value else {}
        current_auth = getattr(gateway, "auth_value", {}) or {}
        if current_auth != decoded_auth:
            gateway.auth_value = decoded_auth

If explicitly clearing a credential should remain possible, an unambiguous signal — an explicit empty auth_headers: [], or auth_type: "none" — would be clearer than inferring intent from an absent field.

Happy to open a PR if that would be useful.

Workaround for anyone else hitting this: send the auth fields on every update, or transmit the literal masked value so the preserve path is taken. Note that the wipe leaves auth_type intact, so a client that decides whether to re-send by diffing the auth type will not notice that anything needs re-sending.

Dominant language
Python
Stars
4.5k
Forks
877
Avg merge
3d 12h
Merged PRs (30d)
66

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from IBM/mcp-context-forge

All issues in IBM/mcp-context-forge

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.