@grantex/mcp-auth 3.0
Status. Version 3.0.0 is prepared in the repository and not yet
published to npm. The current published release is
@grantex/mcp-auth@2.0.2; its behaviour is described in the
MCP Auth Server guide. Everything below
describes 3.0.0 as built from source.
@grantex/mcp-auth puts an OAuth 2.1 authorization server in front of an MCP
server and hands the actual grant to Grantex. 3.0 is built for production:
- Durable state. Clients, consent records, pending authorizations, authorization codes with their PKCE challenges, refresh-token bindings and revocations live in Postgres or Redis, so a restart or a second replica loses nothing.
- The current MCP authorization specification (2026-07-28): protected
resource metadata (RFC 9728), resource indicators with audience binding
(RFC 8707), PKCE S256 only,
issin authorization responses (RFC 9207), and OAuth Client ID Metadata Documents. A conformance suite maps each server-side MUST of the specification, plus the Security Best Practices’ confused-deputy requirements (consent and callback bound to the approving browser, CSRF-protected consent), to a test. - A rendered consent page that shows purpose, tools, caps and duration before anything reaches Grantex, and a purpose that Grantex then binds the grant to.
- Tools refused at the MCP server, not merely hidden from
tools/list, with scopes derived from Grantex tool manifests and adecision_requiredchallenge for actions a person must approve.
How a request flows
- An MCP client calls your MCP server without a token and receives
401withWWW-Authenticate: Bearer resource_metadata="…". - It reads the protected-resource metadata, then this server’s authorization-server metadata.
- It sends the user to
/authorizewith PKCE (S256) andresource. The client is either registered (dynamic registration or pre-registered) or identified by an https metadata-document URL. /authorizevalidates the request and renders the consent page.- The Principal approves; only then does the server ask Grantex to
authorize the grant, with the resource as its audience and
grant.purposeas its purpose, and it sets a callback-binding cookie on that browser. The Principal may confirm again in Grantex. - Grantex redirects to
/callback. Only if the browser presents the callback-binding cookie does the server issue a single-use code to the client’s redirect URI withiss. - The client redeems the code at
/token(PKCE verifier, optionalresource); the server exchanges the upstream code and returns the grant token only if its audience is the requested resource. - The MCP server’s
requireMcpAuthverifies every request’s token (signature, issuer, audience, revocation) and refuses anytools/callthe grant does not cover.
Install
While the candidate is being validated, build it from the repository:@grantex/sdk and the database driver you
use (pg or postgres, or ioredis). The drivers are not dependencies of
the package; the storage classes accept any compatible client.
Deploying with Postgres
- Migrations ship in
migrations/(001_mcp_auth_state.sql). They are forward-only;runMigrations()applies each file once, in name order, in one transaction under an advisory lock, and records it inmcp_auth_schema_migrations. To use your own migration tool, apply the same files in name order and record them the same way. - Tables:
mcp_auth_clients,mcp_auth_pending_authorizations,mcp_auth_authorization_codes,mcp_auth_refresh_token_bindings,mcp_auth_consents,mcp_auth_revocations. Put them in a dedicated schema with the connection’ssearch_pathif you share a database. - Secrets at rest: codes, refresh tokens, consent ids and pending
authorization ids are stored only as SHA-256 keys, and client secrets only
as hashes. The
recordcolumn holds binding data in clear JSON (client, redirect URI, PKCE challenge, scopes, resource) and, for an issued code, the upstream Grantex code for up tocodeExpirationSeconds. That code is a single-use credential: restrict access to the tables and their backups. - Single use is enforced with
DELETE … RETURNING: of any number of concurrent redemptions of one code, exactly one succeeds. postgres(postgres.js) works throughfromPostgresJs(sql).
Deploying with Redis
- Requires Redis 6.2 or later (
GETDEL). Expiring records carry a matchingPXTTL; the refresh-token take runs as a Lua script. - Client registrations have no TTL: enable AOF or RDB persistence, or registered clients disappear with a Redis restart.
- Use a distinct
keyPrefixper deployment when sharing an instance. - node-redis works with
{ send: (command, args) => client.sendCommand([command, ...args]) }.
Operating it
- Replicas. Any number of processes can share one storage. Rate limits
are per process (10/min for
/authorize, 20/min for/token,/consent,/introspect,/revoke, 100/min otherwise); enforce global limits at your proxy. - TLS and proxies.
issuermust be the public https origin (http is accepted only for localhost). The consent form checksOriginagainst it, so a proxy must preserve the browser’sOriginandSec-Fetch-Siteheaders. - Grantex callback. Register
{issuer}/callback(orcallbackUrl) as a redirect URI on the Grantex agent. - Storage failures refuse requests (500 at the authorization server, 503
from
requireMcpAuthwhen revocation state is unreadable); they never grant access. InMemoryStorage(@grantex/mcp-auth/testing) is for tests and refuses to start withNODE_ENV=production.
Configuring the authorization server
Endpoints
Clients
- Client ID Metadata Documents (preferred by the specification). The
client uses an https URL as
client_id. The server fetches it with SSRF protections: only port 443 unlessallowedPortssays otherwise; every address the host resolves to must be public (loopback, private, link-local, carrier-grade NAT, multicast, documentation, AS112, AMT, NAT64, 6to4 and IPv4-mapped ranges are refused) and the connection is pinned to the vetted address; redirects are not followed; the body is limited in size and time; the document must name its own URL asclient_id, carryclient_nameand https or loopbackredirect_uris, and usetoken_endpoint_auth_method: none. Results are cached perCache-Control. Any failure isinvalid_clientwith a reason code, never a redirect. Restrict hosts withallowedHosts. - Dynamic registration (
POST /register) remains for older clients. The client secret is returned once and stored as a hash. - Pre-registered clients: write a
ClientRegistrationwithstorage.putClient()(usehashClientSecret()for a confidential client).
The consent page
GET /authorize renders the page for every valid request; nothing is sent to
Grantex until the Principal approves. This is what the specification requires
of an authorization server that forwards to a third-party authorization server
with one static client.
The page shows:
- the application and the client (flagged when it identified itself with a metadata document, whose name is not verified);
- the host the Principal will be sent back to, prominently, with a warning when every redirect URI is on localhost;
- purpose and duration from
grant. Whengrant.purposeis set, the page says that Grantex records the purpose on the grant; when it is not, the page says nothing about recording one. Either way it says that call limits are declared by the service and enforced only where the service applies them. The data region row always reads “None declared”, because a grant made through mcp-auth cannot carry a data region (see Purpose); - the service (
resourceNameandresource); - each tool the requested scopes cover with its permission and declared caps, and which tools need a decision grant;
- the scopes requested.
Content-Security-Policy: default-src 'none'; script-src 'none'; style-src 'sha256-…'; img-src <logo origin>; form-action 'self' https:; frame-ancestors 'none'; base-uri 'none',
Cache-Control: no-store, X-Frame-Options: DENY and
Referrer-Policy: same-origin. The form carries a CSRF token and the page
sets a per-consent __Host- cookie (Secure, HttpOnly, SameSite=Strict); both
are stored only as hashes in a consent record that is consumed exactly once.
A submission from another site is refused before the record is touched.
Bound to the approving browser. Approval sets a second __Host- cookie
(Secure, HttpOnly, SameSite=Lax, so it survives the return from Grantex)
whose hash is stored on the pending authorization. /callback issues a code
only when the returning browser presents it; otherwise it answers 403 and
the authorization is spent. This stops an attacker who approves consent for
their own client from sending the Grantex consent link to someone else and
collecting that person’s code.
Tested. The page is checked in Chromium at 375 px (no overflow, 44 px
touch targets, stylesheet applied under the CSP) and with axe-core against
WCAG 2.0, 2.1 and 2.2 A/AA rules at mobile and desktop widths.
Customising it
Protecting the MCP server
requireMcpAuth (Express; the Hono version takes the same options) requires
audience and revocations, and answers:
The guard validates grant tokens as
spec/grant-token-0.6.md
(“Validation”) requires:
- Algorithms. Only
RS256andES256. Thealgorithmsoption may narrow that to one of them; a list naming any other algorithm (PS256,EdDSA,HS256, …) throws when the middleware is created. typ. Must beat+jwt(orapplication/at+jwt). The auth service sets it on every grant token; only a pre-0.6 token (nourn:grantex:grant, withscp), issued before it did, may omit it.- Grant tokens only. A token must carry
urn:grantex:grant(0.6) orscp(before 0.6). The auth service’s OAuth access tokens, which are alsoat+jwtbut carry onlyclient_id,scopeand acnf.jkt, are refused. - Scopes. Read from the space-delimited
scope, falling back toscpwhen a 0.6 token omitsscope(a grant with a scope containing whitespace). A pre-0.6 token is read fromscp, because itsscopewas a lossy join. An emptyscope(orscp: []) is an empty scope set, as the SDK verifiers read it: the token is admitted only where neitherscopesnortoolsrequires a scope. - Grant fields.
agentDid,developerId,grantIdanddelegationDepthon the verified grant come fromurn:grantex:grant, then from the legacyagt,dev,grntanddelegationDepth. A 0.6 token must name its agent and developer in one form or the other. - Proof of possession is not checked. Validation step 5 of the profile
requires a resource server to verify proof of possession when the token
has
cnf.jkt. The guard does not: a key-bound grant token is admitted as a bearer token. If your server needs sender-constrained tokens, verify the DPoP proof (RFC 9449) yourself and compare its key thumbprint withraw.cnf.jktof the verified grant (req.mcpGrant, orc.get('mcpGrant')in Hono) before acting on the call.
GRANT_TOKEN_LEGACY_CLAIMS=false, the 0.7 default), and so does
the developer check of grantexDecisionVerifier. As in the SDK verifiers, a
claim present with null or the wrong type, legacy aliases included, is
refused rather than treated as absent, and so is a 0.6 token whose standard
claim and legacy alias disagree.
A batch with one refused call is refused as a whole. Mount it after
express.json(): with tools configured, a body the guard cannot read as
JSON-RPC 2.0 is refused, so a handler that parses the raw body itself can
never act on a call the guard did not check. Every refusal is reported to
onDenial with a low-cardinality reason (missing_token, invalid_token,
grant_revoked, insufficient_scope, tool_not_granted,
manifest_unknown_tool, body_not_parsed, decision_required,
decision_invalid, …) for metrics.
From 3.0.0 the guard refuses to start without a revocation
configuration: requireMcpAuth, the Hono version and
createMcpResourceGuard() throw when revocations is missing, or is neither
an object with an isTokenRevoked(jti) function nor 'none'. A guard that
cannot see revocations accepts a revoked token until it expires, so running
without one has to be a stated choice: revocations: 'none' is the explicit
opt-out. It starts, logs a warning, and neither checks revocation nor
requires a jti, as a 2.x guard without revocations did. The MCP server
must also never forward the
client’s access token to upstream APIs; the guard exposes the verified grant
on the request, not a token to pass on.
filterToolsForGrant() can also hide ungranted tools from tools/list.
Framework-neutral hosts can use createMcpResourceGuard() directly.
The exact challenge formats are specified in
spec/mcp-auth-challenges.md.
Extension points
- Scopes from manifests.
manifests(authorization server) andtoolPolicyFromManifests()(MCP server) read plain manifest JSON in the 0.5 form ("tool": "read") and the 0.6 form ("tool": { "permission": "write", "caps": {…}, "requires_decision": true }), so a manifest loaded with the SDK’s 0.6 loader can be passed as its JSON. Each tool requirestool:<connector>:<permission>, honouringadmin > delete > write > read. Duplicate tool names across manifests are refused unless you passtoolName. - Decision grants. Every call to a
requires_decisiontool goes to the configuredDecisionVerifier, which returnsvalid,absentorinvalidwith a sub-reason (action_mismatch,expired,consumed,same_approver). A verifier that returnsvalidmust consume the decision grant atomically first, so one grant never authorises two calls; a batch with more than one such call is refused before any verifier runs. Without a verifier, such calls are refused:
- Grantex decision grants.
grantexDecisionVerifier(options)is the referenceDecisionVerifierfor decision grants issued by the Grantex auth service (spec/decision-grant.md). It reads one grant, or two comma-separated grants for a decision infour_eyes_on(the manifest’s or the grant’surn:grantex:decision:v1entry’s), from thegrantex-decision-grantrequest header (headerto change it); derives the semantic action from the tool name and the call’scase_id,decision,subject,amountand the manifest’sdecision_fields; requires the access token’s developer (urn:grantex:grant.developer_id, or the legacydev) and a connector from a manifest-derived tool policy (a call without either is refused asmalformed); askscaseVersion(caseId, check)for the case’s current version from your own case state; verifies the grants withverifyand consumes them withconsume, answeringvalidonly after the issuer confirmed consumption (consume_unavailableotherwise). PassverifyDecisionGrantsand(set, context) => grantex.decisions.consume(set, context)from@grantex/sdk0.6 or later; they are injected so this package does not depend on an unreleased SDK.contextis{ agentDid, grantId, grantToken }: the agent (agt, its DID) and grant of the access token, and the access token itself as the guard verified it (check.grantToken). Pass it on, because an auth service withDECISION_GRANT_AGENT_BINDING=trueestablishes the calling agent fromgrantTokenand consumes a decision requested for an agent only with that agent’s live grant token (wrong_agentotherwise). The token goes only toconsume, and so only to the auth service that issued it. An SDK that predatesagentDidorgrantTokendrops them and consumes as before, and an auth service with the binding off ignores them. Refusals carry the SDK’s sub-reason (action_mismatch,wrong_case,wrong_agent,case_changed,expired,consumed,same_approver,four_eyes_incomplete,malformed, …) in thedecision_invalidbody. Consumption spends the grant: if the tool call fails afterwards, a person has to approve again. The guard also reads the access token’surn:grantex:decision:v1entries (spec/grant-token-0.6.md): a tool listed there needs a decision grant even when its manifest does not declarerequires_decision, and a token whose decision entries cannot be read is refused for everytools/call(decision_invalid/malformed_authorization_details). - Authorize parameters.
grant.authorizeParamsreturns extra parameters for the Grantex authorize call, for parameters a newer Grantex server accepts. It cannot override the agent, principal, scopes, audience, redirect URI, state or purpose: it may repeatgrant.purpose, but a differentpurpose, or one whengrant.purposeis unset, refuses the authorization with500 server_errorbefore Grantex is called. The purpose sent is always the one the consent page showed.
Purpose
grant.purpose is shown on the consent page and, once the Principal
approves, sent to Grantex as the purpose of POST /v1/authorize. Grantex
stores it on the grant and carries it in the grant token’s
authorization_details (urn:grantex:tools:v1, one entry per connector),
where the SDKs’ enforce() checks it against each tool’s allowed_purposes.
Every @grantex/sdk version sends it; a Grantex server that predates
purpose-bound grants ignores it, which is why the answer is checked.
Use a purpose Grantex accepts. createMcpAuthServer checks only that
grant.purpose is well formed. Whether Grantex accepts the term is for
Grantex to decide, and it does not publish its purpose vocabulary in its
metadata (/.well-known/oauth-authorization-server), so mcp-auth cannot
check the term at start-up without keeping a copy that could drift. A
well-formed term outside the vocabulary, such as marketing.analytics,
therefore starts cleanly and then fails every authorization after the
Principal approves the consent page: the client receives
error=invalid_scope, and warn receives Grantex’s reason, error code
and request id. Use a term from the
purpose vocabulary or a private
x-<org>.<term>, and complete one authorization after each deployment
that changes grant.purpose.
A purpose needs a connector to bind to, so offer
tool:<connector>:<permission>
scopes (from manifests) and have clients request at least one; a request
for a scope such as profile alone is refused.
Data region. grant.dataRegion is not supported. POST /v1/authorize,
the Grantex endpoint mcp-auth calls, takes no data region: Grantex builds
the grant’s authorization_details from the purpose and scopes alone. A
grant made through mcp-auth therefore cannot carry a data region, and the
consent page could only promise a restriction the grant does not carry.
createMcpAuthServer throws at start-up when it is set; remove it, and say
where data is held in the privacy policy linked from the page
(consentUi.privacyUrl) instead. Grant tokens from other flows can carry
data_region in authorization_details; the SDKs report it but do not
yet evaluate it (see purpose-bound grants).
Conformance
packages/mcp-auth/tests/conformance/mcp-authorization-2026-07-28.test.ts
lists every MUST of the MCP authorization specification dated 2026-07-28,
plus the confused-deputy requirements from the MCP Security Best Practices
(consent and the authorization state bound to the approving browser and
verified at the callback; CSRF protection on the consent form). Each
requirement on an authorization server or MCP server has a test, except
SEC-12 (the MCP server must not pass the client’s token to upstream APIs),
which only the host application can meet and is listed with that reason.
Client requirements are listed as out of scope. Run it with npx vitest run tests/conformance.
Migrating from 2.x
Suggested order: deploy storage and run migrations; set
resource; update MCP
clients’ expectations of /authorize (browsers follow the page; nothing else
changes for them); add audience, revocations and tools to
requireMcpAuth; then remove the old store options.
Known limitations
- The Grantex principal for every grant is the OAuth
client_id, not the person who approved; all users of one client share it. SeeFINDINGS.md. - A data region cannot be declared:
grant.dataRegionis refused at start-up, becausePOST /v1/authorizetakes no data region (see Purpose). - A purpose outside the Grantex vocabulary is found only when Grantex refuses it, after the Principal approves the consent page, not at start-up (see Purpose).
- Rate limits are per process.
onTokenIssuedis declared but not called.