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
.envfile - 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
- https://owasp.org/www-project-mcp-top-10/2025/MCP01-2025-Token-Mismanagement-and-Secret-Exposure
- https://modelcontextprotocol.io/