Skip to main content
intermediatePart 6

The wrong valid token: authenticating an MCP tools server with authentik

· 16 min read
Rafael Fernandes
NLP Engineer & Tech Writer at WiLine
Share:
authentik+Model Context Protocol

Part 5 gave an agent its own identity at the gateway: a token issued by authentik over client credentials, carrying a scope, expiring in five minutes. It ended by naming what it had not covered — the MCP tools server from agent orchestration part 4 still trusts anything that can reach its port, issue_refund included.

That post was explicit about the limit of what it had built:

The MCP server still trusts everyone. Scoping happens in the client. Anything that can reach 127.0.0.1:8770 can call issue_refund directly, agent or not.

Splitting the toolbox per role stopped an agent from reaching a tool it shouldn't. It did nothing about a curl. This part closes that.

Reproducibility

The same box as Parts 3 through 5: one WEC Instance, 8 vCPU AMD EPYC 7601, 15 GB RAM, Ubuntu 22.04, Python 3.10. authentik 2026.8.1 on host port 9100. fastmcp 4.0.2, mcp 2.1.1, langchain 1.4.0. The MCP server is office_tools.py from agent orchestration part 3, unchanged except for the lines shown here, still on 127.0.0.1:8770.

Plain HTTP on a private LAN, as throughout this series.

One client or two​

The obvious move is to reuse Part 5's agent-gateway client and add a second scope to it. It already exists, the agent already holds its credentials, and it would work.

Don't. If one credential opens both the gateway and the tools server, then a leaked gateway key also issues refunds, and the blast radius of losing it doubles. The whole thread running through Part 4's groups and Part 5's grant types is that a credential should do one job.

So the tools server gets its own client, its own scope, and — because in authentik every provider is its own issuer — its own issuer. That last part turns out to matter more than expected, and we get to it in Step 4.

Prerequisites​

  • The authentik from Part 3, with the agent-gateway client from Part 5
  • The MCP server and virtualenv from agent orchestration part 3
  • fastmcp 4.0.0 or newer — the client-credentials helper discussed in Step 5 does not exist before it

Step 1 — A second client, in three parts​

This is Part 5's procedure with different names, so it is deliberately terse here. If any of it is unfamiliar, Part 5 walks through the same three screens with figures.

The provider. Applications → Providers → New Provider → OAuth2/OpenID → Next. Name agent-tools, Client Type Confidential, Redirect URIs empty, and in Grant Types uncheck everything except Client credentials.

The scope. Customization → Property Mappings → New Property Mapping → Scope Mapping. Mapping Name mcp-invoke, Scope name mcp:invoke, expression return {}. The two name fields are different things: the first is authentik's label, the second is the string that lands in the token.

The application. Applications → Applications → New Application ▾ → with Existing Provider…, named Agent Tools, slug agent-tools typed by hand, provider agent-tools, hidden from the dashboard. Then Edit the provider → Advanced protocol settings → Scopes, and move mcp-invoke into Selected.

Verify the grant types from the database rather than the form, which is Part 4's lesson:

cd ~/authentik && docker compose exec server ak shell -c "
from authentik.providers.oauth2.models import OAuth2Provider
for p in OAuth2Provider.objects.all():
print(f'{p.name}: {p.grant_types}')" 2>/dev/null | tail -5
Provider for Langfuse: ['authorization_code', 'implicit', 'urn:ietf:params:oauth:grant-type:device_code']
Provider for LiteLLM: ['authorization_code', 'implicit', 'hybrid', 'refresh_token', 'client_credentials', 'password', 'urn:ietf:params:oauth:grant-type:device_code']
agent-gateway: ['client_credentials']
agent-tools: ['client_credentials']

Two clients trimmed to one grant each, and — visible in the same output — the LiteLLM provider from Part 4 still carrying password and five others nobody chose. Part 5 said to go back and fix those. This is what not doing it looks like.

Step 2 — A token, and why it is a different token​

Read the credentials out and keep them beside the gateway's, in their own directory:

cd ~/authentik
read -r TID TSEC < <(docker compose exec -T server ak shell -c "
from authentik.providers.oauth2.models import OAuth2Provider as P
p = P.objects.get(name='agent-tools')
print(p.client_id, p.client_secret)" 2>/dev/null | tail -1)

mkdir -p ~/mcp-auth && cd ~/mcp-auth
umask 077
cat > .env <<EOF
AK_TOKEN_URL=http://10.80.4.212:9100/application/o/token/
AK_CLIENT_ID=$TID
AK_CLIENT_SECRET=$TSEC
EOF
cp ~/agent-auth/token.sh ~/mcp-auth/token.sh

The helper is Part 5's, unchanged — it takes a scope and returns an access token.

~/mcp-auth/token.sh mcp:invoke | jq -R 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'
{
"iss": "http://10.80.4.212:9100/application/o/agent-tools/",
"sub": "545a532b77a0f877339a5e4312970069deeb9ded604494c510debdfdce9c970d",
"aud": "xNxmOEyPMPVThmxgLsyvTCHa3qvnpVjQSOY0bZck",
"exp": 1789050030,
"iat": 1789049730,
"scope": "mcp:invoke"
}

The decoded tools token showing an issuer ending in agent-tools, an audience different from the gateway client, and scope mcp

Three fields differ from the gateway's token and all three are load-bearing. The issuer ends /agent-tools/ rather than /agent-gateway/. The audience is a different client ID. The scope is mcp:invoke. exp minus iat is 300 seconds, same as Part 5 — remember that number, it comes back in Step 5.

Attaching a scope is not granting it

If the token comes back with "scope": "", the mapping exists but is not attached to the provider. authentik's own help text on that pane: "Select which scopes can be used by the client. The client still has to specify the scope to access the data." Attaching makes it available; the client still has to ask.

Step 3 — The verifier​

FastMCP validates JWTs with JWTVerifier, which takes the four things authentik just told us. Back up the server first, since it is the artefact two published parts are built on:

cp ~/mcp-tools/office_tools.py ~/mcp-tools/office_tools.py.bak

Its configuration goes in a file rather than the source:

cd ~/mcp-tools
umask 077
cat > .env.mcp <<'EOF'
MCP_JWKS_URI=http://10.80.4.212:9100/application/o/agent-tools/jwks/
MCP_ISSUER=http://10.80.4.212:9100/application/o/agent-tools/
MCP_AUDIENCE=<the agent-tools client id>
EOF

Then two changes to office_tools.py — an import, and a verifier where the bare constructor was:

~/mcp-tools/office_tools.py
from fastmcp import FastMCP, Context
from fastmcp.server.auth.providers.jwt import JWTVerifier

verifier = JWTVerifier(
jwks_uri=os.environ["MCP_JWKS_URI"],
issuer=os.environ["MCP_ISSUER"],
audience=os.environ["MCP_AUDIENCE"],
required_scopes=["mcp:invoke"],
)

mcp = FastMCP("office-tools", auth=verifier)

That is the entire server-side change. JWTVerifier fetches the JWKS, matches each token's kid to a key, and checks signature, issuer, audience, expiry and scope before any tool runs.

ssrf_safe and private addresses

JWTVerifier takes an ssrf_safe argument that defaults to False, and enabling it sounds unambiguously like a good idea. On a setup like this one it breaks the verifier. FastMCP's SSRF guard rejects on IP, and its own error text is "Private, loopback, link-local, and reserved IPs are not allowed" — the check explicitly covers 10.x, 172.16-31.x, 192.168.x and 127.x. Our JWKS is at 10.80.4.212, so the fetch never leaves the process.

If you want the guard on and your identity provider is genuinely internal, the module reads FASTMCP_SSRF_TRUST_PROXY, which skips DNS resolution and the IP blocklist entirely.

Restart with the new environment loaded:

pkill -f office_tools.py
cd ~/mcp-tools && set -a && source .env.mcp && set +a && \
nohup ./.venv/bin/python -u office_tools.py > server.log 2>&1 &
sleep 4 && tail -8 server.log

The server restarting, its startup log reporting transport and port with no mention of authentication

Read that startup log carefully, because of what is missing from it. It reports the transport, the mode and the port, and says nothing whatsoever about authentication. A server with a verifier attached and a server without one log the same lines. There is no "auth enabled" message to look for, so the only way to know the verifier took is to make a request — which is the next step.

Step 4 — Three requests​

The same shape as Part 5's three status codes, because it reads well and because the middle one is the interesting one:

TOOLS=$(~/mcp-auth/token.sh mcp:invoke)
GW=$(~/agent-auth/token.sh gateway:invoke)

req() {
curl -sS -o /dev/null -w "$1 HTTP %{http_code}\n" -X POST http://127.0.0.1:8770/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
${2:+-H "Authorization: Bearer $2"} \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
}

req "no token "
req "gateway token" "$GW"
req "tools token " "$TOOLS"
no token HTTP 401
gateway token HTTP 401
tools token HTTP 200

Three requests to the MCP server printing no token 401, gateway token 401 and tools token 200

The first line is the gap agent orchestration part 4 admitted to, now closed. Before this change that request returned the full five-tool catalogue to anybody who asked — and with the right token, it still does:

curl -sS -X POST http://127.0.0.1:8770/mcp \
-H "Authorization: Bearer $TOOLS" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
| tr -d '\r' | sed -n 's/^data: //p' | jq '.result.tools | length, (.[].name)'

The tools list returned to an authorised caller: five tools, find_customer, list_availability, book_slot, open_invoices and issue_refund

Same five tools as before. Authentication changed who may ask, not what the server does.

The second is the one worth pausing on. That is a valid, unexpired, correctly signed authentik token that works perfectly well against the gateway, and the tools server refuses it. Ask the server why — sending the request again first, so the answer is at the end of the log rather than buried under whatever has hit the server since:

GW=$(~/agent-auth/token.sh gateway:invoke)

curl -sS -o /dev/null -w 'gateway token: HTTP %{http_code}\n' -X POST http://127.0.0.1:8770/mcp \
-H "Authorization: Bearer $GW" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

tail -12 ~/mcp-tools/server.log
WARNING Bearer token rejected for client aObZ8KvKsKVdQ4MuSsTINp0f65Puz5fzXUUBC6iQ
: issuer mismatch (got 'http://10.80.4.212:9100/application/o/agent-gateway/',
expected 'http://10.80.4.212:9100/application/o/agent-tools/')
INFO Auth error returned: invalid_token (status=401)

The server log naming the rejected client and printing both the received and expected issuer, followed by a generic invalid_token response

It failed on iss, not on scope. That is the separate provider paying off. In authentik each provider is its own issuer, so a token from the wrong client is rejected at the first gate, before audience or scope are looked at. Had we taken the easy road and added a second scope to the gateway's client, the same request would have got several checks further before required_scopes turned it away. Same outcome, more surface.

Two other things in that log. It names the offending client by ID, and prints both the issuer it got and the one it wanted — so debugging is a tail, not a guess. And the caller receives only invalid_token, which is correct: telling a client which issuer you expected is telling an attacker what to forge.

Don't grep this log

grep -iE 'invalid|token|401' server.log returns the last line and hides the first. You get invalid_token (status=401) and conclude the server won't say why. It says why, on the line above, wrapped across four lines that your pattern doesn't match. Read it unfiltered.

Step 5 — The client half, and a helper that cannot help​

The tools server is now protected, which means every script from part 3 and part 4 is broken. They connect with no token.

FastMCP documents exactly the right thing for this: ClientCredentialsOAuthProvider, which runs the client-credentials grant itself and — the part that matters given our 300-second tokens — "when the token expires it is re-acquired automatically on the next request."

It does not work here. Try it and you get:

mcp.client.auth.exceptions.OAuthTokenError: Token exchange failed (404): Not Found

The server log explains what actually happened:

GET /.well-known/oauth-authorization-server 404
POST /token 404
GET /.well-known/oauth-protected-resource 404
GET /.well-known/oauth-protected-resource/mcp 404
GET /.well-known/openid-configuration 404

The provider takes the MCP server's URL, not a token endpoint, because "the token endpoint is discovered from the server's OAuth metadata." Our server publishes none — and FastMCP's own documentation says so, on a different page: "TokenVerifier focuses exclusively on token validation without providing OAuth discovery metadata."

So the two pages are consistent and the combination is a documented dead end. You just have to read both to find that out, and the failure tells you none of it: four 404s you only see if you are watching the server, a fallback that POSTs /token at the resource server, and an error that never mentions discovery.

The path the docs point to instead is the client obtaining its token separately. BearerAuth takes a token you already have and attaches it to every request — worth proving standalone before wiring it into four scripts:

A probe script using BearerAuth with a token from the shell helper, printing all five discovered tool names

That works, so it can be shared. One helper, used by every script in the series:

~/mcp-tools/mcp_auth.py
"""One authenticated MCP client, shared by every agent in the series."""
import os
import subprocess

from fastmcp import Client
from fastmcp.client.auth import BearerAuth

MCP_URL = "http://127.0.0.1:8770/mcp"
TOKEN_SH = os.path.expanduser("~/mcp-auth/token.sh")


def fetch_token(scope: str = "mcp:invoke") -> str:
"""Mint a short-lived token from authentik via the client credentials grant."""
out = subprocess.run([TOKEN_SH, scope], capture_output=True, text=True, check=True)
return out.stdout.strip()


def authed_client() -> Client:
return Client(MCP_URL, auth=BearerAuth(token=fetch_token()))

Each agent then changes by two lines — an import, and what it hands MCPAdapter:

from mcp_auth import authed_client

async with MCPAdapter(authed_client()) as adapter:

MCPAdapter accepts a configured Client as readily as a URL string, which part 3 used for caching without needing it for anything else. Here it is what lets the token in.

BearerAuth is the explicit form. FastMCP also accepts the token as a bare string — Client(url, auth=token) — and adds the scheme itself; its documentation is specific that you "do not include the Bearer prefix" if you do. The class is worth the extra import here only because it makes the intent obvious at the call site.

This reintroduces the expiry problem

BearerAuth holds one fixed string. A token minted when the agent starts is dead 300 seconds later, and nothing re-acquires it — the automatic renewal was the one thing ClientCredentialsOAuthProvider would have given us. For a short run it does not matter. For an agent that waits on a human, as part 3's refund does, it certainly can. Either raise the token lifetime on the provider, or mint per call rather than per client.

Step 6 — Prove a tool actually runs​

Discovery is not execution. A token that lists tools might still fail at the call, so check the thing that touches the database:

TOOLS=$(~/mcp-auth/token.sh mcp:invoke)

curl -sS -X POST http://127.0.0.1:8770/mcp \
-H "Authorization: Bearer $TOOLS" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_customer","arguments":{"query":"Maria"}}}' \
| tr -d '\r' | sed -n 's/^data: //p' | jq -c '.result.content[0].text // .error'
"[{\"id\":1,\"name\":\"Maria Alvarez\",\"email\":\"maria@example.com\"}]"

A tools/call for find_customer, authenticated with a bearer token, returning Maria Alvarez&#39;s row from the database

Drop the Authorization header and the same call returns 401. A real row with a token, nothing without — that is authentication reaching execution, not just the catalogue.

Skill unlocked 🏅

You can put a JWT verifier in front of an MCP tools server so it refuses anonymous callers and refuses valid tokens issued to a different client — and read the server log to know which of issuer, audience, scope or expiry did the refusing.

Troubleshooting — the errors this run actually produced​

Token exchange failed (404): Not Found​

ClientCredentialsOAuthProvider against a JWTVerifier server. Discovery found nothing and it fell back to guessing. See Step 5; use BearerAuth.

2 validation errors ... Missing required argument​

The token was fine — this comes from inside the tool. params.name is the tool's name, params.arguments are its parameters, and both nest a field called name. find_customer takes query.

The log says only invalid_token​

You grepped it. See the warning in Step 4.

Every script from parts 3 and 4 suddenly fails​

Expected. They connect without a token. Step 5.

What this did and didn't buy you​

Done: an MCP server that authenticates every caller, a credential that is not the gateway's, a refusal that happens at the issuer rather than deep in the scope check, and a log that names which client was turned away and why.

Not done:

  • No per-tool authorisation, and this is less than agent orchestration part 4 promised. That post closed by saying the next step was to "authenticate the tool call, so the server knows which agent is asking and refuses issue_refund to anything that isn't billing." Half of that is now true: the call is authenticated. The other half is not. required_scopes is a property of the server, not of a tool, so every holder of mcp:invoke can call issue_refund exactly as easily as find_customer, and the server still cannot tell the scheduler from the billing agent. Splitting tools per agent, as part 4 did, remains a client-side arrangement.
  • Tokens still expire mid-run, as described in Step 5.
  • Still plain HTTP. A bearer token in the clear is a credential in the clear.
  • The gateway's own provider is still over-permissioned, visible in Step 1's output and unfixed since Part 5 pointed at it.
Finished this tutorial?
Mark it complete to earn A tool server that checks who is asking on your skill path.

What's next​

Per-tool authorisation is the obvious gap and it does not have an obvious answer. required_scopes gates the server; gating issue_refund differently from find_customer means either a second server with its own client, or authorisation inside each tool body reading the verified claims. The first is more boxes and a clean boundary. The second is a check on every tool you ever write, which is precisely the failure mode part 4 warned about for policy-in-the-tool.

Further reading​

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.