Skip to main content

@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, iss in 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 a decision_required challenge for actions a person must approve.

How a request flows

  1. An MCP client calls your MCP server without a token and receives 401 with WWW-Authenticate: Bearer resource_metadata="…".
  2. It reads the protected-resource metadata, then this server’s authorization-server metadata.
  3. It sends the user to /authorize with PKCE (S256) and resource. The client is either registered (dynamic registration or pre-registered) or identified by an https metadata-document URL.
  4. /authorize validates the request and renders the consent page.
  5. The Principal approves; only then does the server ask Grantex to authorize the grant, with the resource as its audience and grant.purpose as its purpose, and it sets a callback-binding cookie on that browser. The Principal may confirm again in Grantex.
  6. 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 with iss.
  7. The client redeems the code at /token (PKCE verifier, optional resource); the server exchanges the upstream code and returns the grant token only if its audience is the requested resource.
  8. The MCP server’s requireMcpAuth verifies every request’s token (signature, issuer, audience, revocation) and refuses any tools/call the grant does not cover.

Install

While the candidate is being validated, build it from the repository:
Install the resulting tarball with @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 in mcp_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’s search_path if 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 record column holds binding data in clear JSON (client, redirect URI, PKCE challenge, scopes, resource) and, for an issued code, the upstream Grantex code for up to codeExpirationSeconds. 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 through fromPostgresJs(sql).

Deploying with Redis

  • Requires Redis 6.2 or later (GETDEL). Expiring records carry a matching PX TTL; 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 keyPrefix per 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. issuer must be the public https origin (http is accepted only for localhost). The consent form checks Origin against it, so a proxy must preserve the browser’s Origin and Sec-Fetch-Site headers.
  • Grantex callback. Register {issuer}/callback (or callbackUrl) as a redirect URI on the Grantex agent.
  • Storage failures refuse requests (500 at the authorization server, 503 from requireMcpAuth when revocation state is unreadable); they never grant access.
  • InMemoryStorage (@grantex/mcp-auth/testing) is for tests and refuses to start with NODE_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 unless allowedPorts says 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 as client_id, carry client_name and https or loopback redirect_uris, and use token_endpoint_auth_method: none. Results are cached per Cache-Control. Any failure is invalid_client with a reason code, never a redirect. Restrict hosts with allowedHosts.
  • Dynamic registration (POST /register) remains for older clients. The client secret is returned once and stored as a hash.
  • Pre-registered clients: write a ClientRegistration with storage.putClient() (use hashClientSecret() for a confidential client).
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. When grant.purpose is 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 (resourceName and resource);
  • each tool the requested scopes cover with its permission and declared caps, and which tools need a decision grant;
  • the scopes requested.
Security. The page is server-rendered with an escaping template and no script. It is served with 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 RS256 and ES256. The algorithms option may narrow that to one of them; a list naming any other algorithm (PS256, EdDSA, HS256, …) throws when the middleware is created.
  • typ. Must be at+jwt (or application/at+jwt). The auth service sets it on every grant token; only a pre-0.6 token (no urn:grantex:grant, with scp), issued before it did, may omit it.
  • Grant tokens only. A token must carry urn:grantex:grant (0.6) or scp (before 0.6). The auth service’s OAuth access tokens, which are also at+jwt but carry only client_id, scope and a cnf.jkt, are refused.
  • Scopes. Read from the space-delimited scope, falling back to scp when a 0.6 token omits scope (a grant with a scope containing whitespace). A pre-0.6 token is read from scp, because its scope was a lossy join. An empty scope (or scp: []) is an empty scope set, as the SDK verifiers read it: the token is admitted only where neither scopes nor tools requires a scope.
  • Grant fields. agentDid, developerId, grantId and delegationDepth on the verified grant come from urn:grantex:grant, then from the legacy agt, dev, grnt and delegationDepth. 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 with raw.cnf.jkt of the verified grant (req.mcpGrant, or c.get('mcpGrant') in Hono) before acting on the call.
The guard therefore keeps working when the auth service stops issuing the legacy claims (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) and toolPolicyFromManifests() (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 requires tool:<connector>:<permission>, honouring admin > delete > write > read. Duplicate tool names across manifests are refused unless you pass toolName.
  • Decision grants. Every call to a requires_decision tool goes to the configured DecisionVerifier, which returns valid, absent or invalid with a sub-reason (action_mismatch, expired, consumed, same_approver). A verifier that returns valid must 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 reference DecisionVerifier for decision grants issued by the Grantex auth service (spec/decision-grant.md). It reads one grant, or two comma-separated grants for a decision in four_eyes_on (the manifest’s or the grant’s urn:grantex:decision:v1 entry’s), from the grantex-decision-grant request header (header to change it); derives the semantic action from the tool name and the call’s case_id, decision, subject, amount and the manifest’s decision_fields; requires the access token’s developer (urn:grantex:grant.developer_id, or the legacy dev) and a connector from a manifest-derived tool policy (a call without either is refused as malformed); asks caseVersion(caseId, check) for the case’s current version from your own case state; verifies the grants with verify and consumes them with consume, answering valid only after the issuer confirmed consumption (consume_unavailable otherwise). Pass verifyDecisionGrants and (set, context) => grantex.decisions.consume(set, context) from @grantex/sdk 0.6 or later; they are injected so this package does not depend on an unreleased SDK. context is { 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 with DECISION_GRANT_AGENT_BINDING=true establishes the calling agent from grantToken and consumes a decision requested for an agent only with that agent’s live grant token (wrong_agent otherwise). The token goes only to consume, and so only to the auth service that issued it. An SDK that predates agentDid or grantToken drops 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 the decision_invalid body. Consumption spends the grant: if the tool call fails afterwards, a person has to approve again. The guard also reads the access token’s urn:grantex:decision:v1 entries (spec/grant-token-0.6.md): a tool listed there needs a decision grant even when its manifest does not declare requires_decision, and a token whose decision entries cannot be read is refused for every tools/call (decision_invalid / malformed_authorization_details).
  • Authorize parameters. grant.authorizeParams returns 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 repeat grant.purpose, but a different purpose, or one when grant.purpose is unset, refuses the authorization with 500 server_error before 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. See FINDINGS.md.
  • A data region cannot be declared: grant.dataRegion is refused at start-up, because POST /v1/authorize takes 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.
  • onTokenIssued is declared but not called.
Last modified on September 27, 2026