MCP OAuth and Authorization: Key Hashes, Bearer, Roles
Authorization for MCP is a bearer credential plus the decisions a server makes about it. A hosted gateway hashes the key at rest with a server-side salt, validates it in one indexed lookup that rejects revoked and expired keys, resolves the team from the key rather than from the request body, checks the caller's team role, and clamps the policy a token holder may edit against the plan that pays…
Short answer: Authorization for MCP is a bearer credential plus the decisions a server makes about it. A hosted gateway hashes the key at rest with a server-side salt, validates it in one indexed lookup that rejects revoked and expired keys, resolves the team from the key rather than from the request body, checks the caller's team role, and clamps the policy a token holder may edit against the plan that pays for it.
Key takeaways
- The plaintext key is returned once and never stored. Issuance hands back the raw key, and the database keeps a hash of the salt, a colon and the key, so a database read cannot produce a working credential.
- Validation is one lookup and it refuses before it writes. A revoked or expired key returns invalid without touching the last-used counter, so revocation does not masquerade as activity.
- The tenant comes from the credential, not from the caller. A team identifier passed as an argument is clamped to the team the key belongs to — the identity a call is recorded under is not a client choice.
- Authentication and authorization are two different questions. A valid key says who is calling; a team role decides whether that identity may edit the policy or read the audit trail.
- Every cap is enforced on the identity, not on the model. The budget tool, the per-key rate limit and the retention window all key off the same team and key, which is why revoking a key ends the REST path and the tool path in the same instant.
- Do this next: replace any shared long-lived key with per-workflow keys that carry an expiry, then read one row in the logs view and confirm which identity produced it.
Why mcp oauth looks simple until a gateway is in the middle
The protocol part is small. The Model Context Protocol makes authorization optional, and where an HTTP transport supports it, the server is expected to behave as an OAuth 2.1 resource server: a bearer credential over TLS, and — since the 2025-06-18 revision of the specification — a protected-resource metadata document that tells a client where the authorisation server lives (MCP authorization, RFC 9728). A local stdio server needs none of it.
A gateway needs the rest of it. Once several hosts, several teams and one budget share an endpoint, four questions stop being protocol questions and become product decisions: what does the server keep at rest, what does it refuse and when, which identity owns the call, and what may that identity change? Each of those has an answer in code, and this page walks the four in order.
mcp authorization begins at the key hash, not the header
The credential a client pastes into a host config block is the only copy of that key in existence. What the server keeps is a digest, and the digest is salted so that two databases cannot be compared against each other:
# backend/smartgate/core/api_key_hash.py — source lines 6–13 (hash_api_key)
def hash_api_key(raw_key: str) -> str:
"""Must match Next `lib/api-keys` hashApiKey (SMARTGATE_API_KEY_SALT + ':' + raw)."""
salt = settings.api_key_salt
h = hashlib.sha256()
h.update(salt.encode())
h.update(b":")
h.update(raw_key.encode())
return h.hexdigest()
The interesting part is the docstring, which is a contract rather than a comment: the Python side and the Next side must produce the same digest from the same inputs, or a key minted in the dashboard would be rejected by the tool surface that reads the same table. Salting with a server-side secret is what makes a leaked dump useless without the salt, and hashing with a colon separator between salt and key removes the concatenation ambiguity a bare concatenation would leave. A key is never decrypted, because it was never encrypted — verification re-computes the digest and compares.
Key issuance is the first protection against an over-scoped agent
Most credential incidents are not cracking exercises; they are a key that was issued once, copied into a shared config file, and never rotated. Issuance is therefore a policy point, and it runs before the row is written:
# lib/api-keys/index.ts — source lines 43–69 (createApiKey)
async function createApiKey(
teamId: string,
userId: string,
data: { name: string; expiresInDays?: number },
planContext: { plan: Plan; features: Record<string, unknown> | null },
) {
await assertCanCreateApiKey(teamId, planContext.plan, planContext.features);
const { raw, hash, prefix } = generateKey();
const expiresAt = data.expiresInDays
? new Date(Date.now() + data.expiresInDays * 86400000)
: null;
const key = await prisma.apiKey.create({
data: {
name: data.name,
keyHash: hash,
keyPrefix: prefix,
expiresAt,
teamId,
createdById: userId,
},
});
return { id: key.id, name: key.name, rawKey: raw, prefix };
}
Three decisions sit in this excerpt. The capability check comes first, so a plan that does not grant more keys fails before a credential exists. The expiry is optional and stored as an absolute timestamp, which means a host that keeps the key in memory forever still loses access on the day the row says. And the caller gets the raw key exactly once — the response carries it, the table carries only the hash and a short prefix for display, and a support conversation can identify a key without ever seeing it. That is the whole answer to "can you tell me my key again": the answer is a new key.
mcp security best practices: what one validation refuses, and when
Validation answers exactly one question — is this credential real, unrevoked and unexpired — and it answers it before anything else in the request path runs:
# lib/api-keys/index.ts — source lines 92–107 (validateApiKey)
async function validateApiKey(
rawKey: string
): Promise<{ valid: boolean; teamId?: string; keyId?: string }> {
const hash = hashKey(rawKey);
const key = await prisma.apiKey.findUnique({
where: { keyHash: hash },
select: {
id: true,
teamId: true,
revokedAt: true,
expiresAt: true,
},
});
if (!key || key.revokedAt) return { valid: false };
if (key.expiresAt && key.expiresAt < new Date()) return { valid: false };
Order is the design here. The lookup is by hash and by nothing else, so it is one indexed read; the two rejection conditions are checked before the update, which means a revoked key is refused without incrementing its usage counter or moving its last-used timestamp. That ordering has a visible consequence for anyone auditing the trail: a revoked key that is still being tried in the wild leaves no activity in the table, because a credential that fails validation never reaches the recording path. Everything else the request needs — plan, quota, rate limit — is deliberately not decided here. One function, one answer, and the identity it returns is what every later check consumes.
Scope sits outside that function and is where over-provisioning shows up first: the mcp tools reference lists which of the seven tools write, and a key minted for a read-only workflow has no reason to carry the ones that do.
Session sign-in and the mcp api key: two identities at one boundary
A dashboard has users; an MCP surface has keys. They are different identity systems, and a gateway that blurs them ships a privilege-escalation bug. The sign-in provider is explicitly not the tool path:
# auth.ts — source lines 80–92 (authorize)
async authorize() {
const testEmail = "demo@example.com";
let user = await prisma.user.findUnique({ where: { email: testEmail } });
if (!user) {
user = await prisma.user.create({
data: { email: testEmail, name: "Demo User", emailVerified: new Date(), role: "USER" },
});
await prisma.account.create({
data: { userId: user.id, type: "credentials", provider: "demo", providerAccountId: user.id },
});
}
return { id: user.id, email: user.email, name: user.name, image: user.image, role: user.role };
}
This is the credentials provider of the web application, not an MCP authorizer: it resolves a browser session to a user record, and its development convenience — creating the account on first sign-in — is exactly the kind of code that must never be reachable from the tool endpoint. The separation is what makes the two revocation stories independent. Closing a browser session has no effect on a key an agent holds, and revoking the key does not sign anyone out of the dashboard. Keep them apart in your own threat model too: the session answers "which human is looking at this page", the key answers "which tenant may this call be billed to", and a request that arrives with only one of the two should fail the check that needs the other.
Team roles: who may use a token that writes
Per-key identity is necessary and not sufficient: a key can belong to a team while its holder is the wrong member of that team. Role checks sit between the credential and the operation:
# lib/authz/team.ts — source lines 27–34 (requireTeamRole)
async function requireTeamRole(
teamId: string,
minRole: TeamAuthRole,
) {
const user = await getCurrentUser();
if (!user?.id) {
throw new TeamAuthError("Not authenticated", 401);
}
# lib/authz/team.ts — source lines 36–52 (requireTeamRole)
const member = await prisma.teamMember.findFirst({
where: { teamId, userId: user.id },
select: { role: true },
});
if (!member) {
throw new TeamAuthError("Not a team member", 403);
}
const memberRank = ROLE_RANK[member.role] ?? 0;
const requiredRank = ROLE_RANK[minRole] ?? 0;
if (memberRank < requiredRank) {
throw new TeamAuthError("Insufficient team role", 403);
}
return { userId: user.id, role: member.role };
}
Two refusals and one comparison. No session means 401 and no membership means 403 — the distinction matters, because a client that receives 401 can retry with a credential and a client that receives 403 should not. The rank comparison is what turns a role name into an order rather than a set: a member cannot perform an admin action by asking for a different role string, and a role that the rank table does not know resolves to zero, so an unrecognised value fails closed instead of opening a path. Apply the same shape to tool access: decide the minimum role at the call site rather than checking for a role by name, and the answer stays correct when the role list grows.
What a bearer token is still not allowed to change
Governance values are the most attractive target on the page, because the limits that bound an agent are themselves stored settings. The write path refuses before it writes:
# lib/settings/assert-plan-allows.ts — source lines 40–44 (assertGatewayPolicyEditable)
function assertGatewayPolicyEditable(caps: PlanCapabilities) {
if (!caps.teamGatewayPolicyEditable) {
throw new PlanFeatureDisabledError("PRO", "gateway_policy");
}
}
A capability check, a typed error naming the missing capability and the object it protects, and no fallback branch — there is no "edit it anyway" path for an account that lacks the entitlement. This is the difference between a limit and a suggestion: if any token holder could raise the daily cap, the cap would be a number in a form rather than a control, and the first person to hit it would be the one who removes it. Two properties are worth copying into your own deployment. The check runs before the mutation, so a rejected edit leaves the previous policy in force; and the error names the object being protected, which turns a support ticket into a one-line answer about which plan feature is missing.
The mcp gateway is the cap a leaked key cannot argue with
Authorization decides whether a call may run; the budget layer decides how much of it may run. That layer is a tool, and the tool's tenant is taken from the credential that arrived:
# backend/smartgate/api/mcp.py — source lines 198–203 (smart_budget_guard)
@server.tool(
name="smart_budget_guard",
description=TOOL_DESCRIPTIONS["smart_budget_guard"],
annotations=tool_annotations("smart_budget_guard"),
)
async def smart_budget_guard(
# backend/smartgate/api/mcp.py — source lines 233–252 (smart_budget_guard)
_, registry = _app_state()
module = registry.get("budget_guard")
tid = (team_id or "").strip() or bound_team_id()
ctx = _tool_ctx(tid)
params = _non_empty(
action=action,
team_id=tid or None,
text=text,
messages=messages,
model=model,
tokens=tokens or None,
monthly_limit=monthly_limit or None,
completion_tokens=completion_tokens or None,
)
return await _run_with_audit(
"budget_guard",
ctx,
module.process(ctx, **params),
params,
)
Read the middle two lines of the second window as one statement: the team identifier is whatever the caller passed, stripped, or — when that is empty — whatever team the API key is bound to. So an agent can narrow its own scope and cannot widen it, and an audit row can never be written under a team that did not authenticate. The parameters are then filtered before they reach the module, so empty fields are omitted rather than sent as zeroes, which is what stops a "record zero tokens" call from looking like a legitimate meter reading. And the whole thing exits through the same audited path as every other tool, so the cap and the record of it are written by the same code. A stolen key gets a rate limit, a monthly ceiling and a retention window against it before it gets a token — that is the design.
mcp vs api: the schema you publish and the surface you gate
An OpenAPI document and an MCP tool list describe two different surfaces, and the difference is worth being deliberate about, because the documented surface is the one that gets probed:
# backend/smartgate/core/openapi_env.py — source lines 8–10 (openapi_enabled)
def openapi_enabled() -> bool:
raw = os.environ.get("SMARTGATE_OPENAPI_ENABLED", "false").strip().lower()
return raw in ("1", "true", "yes", "on")
A single environment variable decides whether the schema endpoint exists at all, and the default is
off. That is the right default for a gateway: the internal REST surface is not the product, and
publishing a machine-readable map of every path is a decision an operator should make explicitly
rather than inherit from a framework. The MCP side has no equivalent switch, because a tool list is
not a document — it is a response to tools/list, available only to a caller that already
authenticated. The practical rule for the two together: keep the documented surface closed, keep
the discoverable surface authenticated, and treat any page that describes your auth model as
documentation rather than as configuration.
The same split reappears once agents call each other instead of calling a server: the a2a protocol separates the tool owner from the caller, so the authorization model has to name which side holds the key the other one presents.
anthropic mcp clients and the shared state behind revocation
Revocation is only as fast as the cache that holds the check. Whatever a host runs — Claude Desktop, Cursor, Hermes — the enforcement point is shared state the server can reach:
# lib/redis/config.ts — source lines 38–42 (resolveRedisMode)
function resolveRedisMode(): RedisMode {
if (isUpstashRestConfigured()) return "upstash";
if (isTcpRedisConfigured()) return "tcp";
return "none";
}
Three modes and one honest fallback: a REST-based store first, because that is what works where no
outbound TCP socket is available, a direct Redis URL second, and none as the mode that gets
resolved rather than silently defaulted. The mode decides real behaviour, not just storage — a
shared counter store makes a per-key window and a monthly budget identical across every instance,
while the single-process mode makes them local to one of them. That is the trade a self-hosted
Anthropic MCP setup has to make out loud: with none, revocation and quota still work on the
instance that issued the decision and not on its neighbours. Check the resolved mode at deployment
time; the log line is cheaper than an incident review.
token optimization is not key management: the other meaning of token
Half the confusion in this area is lexical. A repository search for token returns tokenizer code
as often as it returns credentials, and the two never belong in the same control:
# backend/smartgate/modules/context_gate/utils.py — source lines 105–111 (get_pure_token)
def get_pure_token(token, model_name):
if "bert-base-multilingual-cased" in model_name:
return token.lstrip("##")
elif "xlm-roberta-large" in model_name:
return token.lstrip("▁")
else:
raise NotImplementedError()
get_pure_token strips the model-specific word-piece prefix from a piece of text — ## for one
multilingual BERT tokenizer, a leading ▁ for XLM-RoBERTa — and raises for anything else rather
than guessing. It is a correctness helper for counting and compression, with no relationship to a
bearer credential: it cannot authenticate anything and nothing authenticates with it. Say so
explicitly in your own review notes, because a security questionnaire that asks how "tokens" are
handled will accept an answer about credential lifetimes and a build ticket that says "token
handling" usually means the other one. Naming the two apart in the codebase is cheaper than
disambiguating them in an incident.
How SmartGate's authorization layer compares
| What it authenticates | What it authorizes | What you pay | |
|---|---|---|---|
| SmartGate (hosted) | A salted-hash API key, per key and per team | Team roles, a plan-clamped policy, a per-key rate limit inside a team ceiling and a monthly cap | Free tier: 2M tokens a month, 120 MCP requests a minute per key (pricing) |
| Self-hosted MCP server | Whatever you implement — often one static token in a config file | Nothing by default; every scope decision is yours to write | Your own infrastructure |
| Plain API gateway with an API key | The key, at the edge | Routing and request limits; tool-level scope and budget are not its vocabulary | Per-request or per-instance pricing |
| A shared team key in a config file | One credential for everybody | Nobody's individual actions, and no per-workflow revocation | The cost of the incident |
The honest reading is that the credential itself is the cheap part. A key is a string and hashing it is a dozen lines. What a gateway adds is the middle column — a role check between the credential and the operation, a plan-clamped policy the token holder cannot raise, and a quota that attaches to the tenant instead of to the request. If one developer runs one local server, none of that earns its place. If an agent runs unattended with a key someone pasted into a config file, all of it does.
How to get started
- Create a key per workflow, with an expiry. One key for a nightly job and one for an interactive host means rotating either one is a one-line change; the security page covers the scopes a key carries.
- Send it as a bearer credential in the
Authorizationheader of your host's config block, and let the connect page generate the block for that host rather than transcribing it. - Read one audit row. The logs view shows the team and the key behind each call; if the row has no identity, the request never authenticated.
- Give the key the smallest role it needs, and keep policy edits in the dashboard, where the capability check and the audit trail both live.
- Then read the two sibling pages — what the gateway does per call in MCP gateway, and where the credential attaches on the wire for both surfaces in MCP vs REST API.
Start on the free tier — 2M tokens a month, all seven tools, 120 MCP requests a minute per key — and compare per-key limits, roles and log retention on the pricing page.
If you need something to point those keys at while you test the refusal paths, the mcp server tutorial stands up a Python server and then governs its traffic, which is the fixture that makes a revocation test reproducible.
Frequently Asked Questions
Limitations and what this does not do
- Bearer credentials are only as safe as their transport. Nothing here defends a key sent over plain HTTP or pasted into a screenshot; TLS and a secrets manager are yours to supply.
- Hashing is not encryption, and it is not a rotation policy. A salted digest makes a database read useless; it does nothing about a key that lives in a shared config file for a year.
- Role ranks are a policy you can get wrong. The rank table defaults an unknown role to zero, which fails closed, but a rank that is too high for a role is a decision only you can review.
- The shared-store mode is not optional in a multi-instance deployment. With no shared store, the per-key window and the monthly counter are local to one process; the page says so and the deployment check is the place to catch it.
- This is not a model host and not a prompt filter. It governs who may call, how often and for how much; it does not inspect what an authorised tool call was asked to do.
Sources
- Model Context Protocol — authorization, including the resource-server model: https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
- Model Context Protocol — latest specification revision: https://modelcontextprotocol.io/specification/2026-07-28
- IETF — OAuth 2.0 Protected Resource Metadata (RFC 9728): https://datatracker.ietf.org/doc/html/rfc9728
- IETF — The OAuth 2.0 Authorization Framework: Bearer Token Usage (RFC 6750): https://datatracker.ietf.org/doc/html/rfc6750
- IETF — Resource Indicators for OAuth 2.0 (RFC 8707): https://datatracker.ietf.org/doc/html/rfc8707
- OWASP — API Security Top 10: https://owasp.org/API-Security/
- SmartGate — security and logs documentation: https://smartgate.network/docs/security
Method note
The code in this article is not transcribed. Every fenced block was cut directly out of the slice
body returned by the SmartGate slice API and re-asserted byte-for-byte as a substring of that body
before publication; the first line inside the fence records the file and the exact source lines.
Symbols were pinned with whole-name containment (rule A level 2) and confirmed by the service's
slot-proof endpoint. The smart_budget_guard excerpt keeps the decorator, the declaration line and
the audited exit and drops the eight Field(...) parameter declarations between them, because the
schema layer is documented on the tools reference; the requireTeamRole excerpt is split after the
401 shape so that the membership lookup and the rank comparison read as one unit. Sections 1:1
(llm guardrails) and 5:1 (agent guardrails) pinned no slice — rule A returned more than one
equally plausible candidate and the pin stage recorded an abstention rather than picking one — so
those two are written from the published specification and the RFCs above, with no code quoted for
them. No third-party identity provider implementation is quoted anywhere on this page.
Slice provenance
| # | SERP keyword | Symbol | File | Source lines | How it was pinned | sha256(12) |
|---|---|---|---|---|---|---|
| 1 | mcp authorization | hash_api_key |
backend/smartgate/core/api_key_hash.py |
6–13 | rule A L2 → slot-proof | aba53f9b1f58 |
| 2 | prompt injection protection | createApiKey |
lib/api-keys/index.ts |
43–69 | rule A L2 → slot-proof | ea3cf28d9b6b |
| 3 | mcp security best practices | validateApiKey |
lib/api-keys/index.ts |
92–107 | rule A L2 → slot-proof | eea805141bb6 |
| 4 | mcp api key | authorize |
auth.ts |
80–92 | rule A L2 → slot-proof | 9dfddb59a9ed |
| 5 | mcp token usage | requireTeamRole |
lib/authz/team.ts |
27–34, 36–52 | rule A L2 → slot-proof | 08de920ed687 |
| 6 | mcp bearer token | assertGatewayPolicyEditable |
lib/settings/assert-plan-allows.ts |
40–44 | rule A L2 → slot-proof | 5bf979730866 |
| 7 | mcp gateway | smart_budget_guard |
backend/smartgate/api/mcp.py |
198–203, 233–252 | rule A L2 → slot-proof | 1f5f74bc48b7 |
| 8 | mcp vs api | openapi_enabled |
backend/smartgate/core/openapi_env.py |
8–10 | rule A L2 → slot-proof | 7683748ab469 |
| 9 | anthropic mcp | resolveRedisMode |
lib/redis/config.ts |
38–42 | rule A L2 → slot-proof | 3a9dd8e9daf4 |
| 10 | token optimization | get_pure_token |
backend/smartgate/modules/context_gate/utils.py |
105–111 | rule A L2 → slot-proof | d0f34a4f3bd0 |
Every fenced block above was cut from the slice body and re-asserted against it byte-for-byte before publication. 10 of 12 sections pinned, 2 abstentions, 0 misses.