New in reflex-enterprise v0.9.4.
MCP Authentication
Every request to the MCP endpoint — and to the
Event Handler API REST endpoints —
requires an app-issued bearer token. A caller-invented UUID is never
accepted, and no MCP tool takes a token argument.
That is the core of the model: the underlying Reflex session token is server-generated and never leaves the server, so a credential can only ever address its own dedicated session — never a browser session, never another agent's — and the number of sessions a client can create is bounded by rate limits.
Tokens come from one of two places. When both are available the client picks.
Anonymous sessions
POST /_reflex/auth/token returns an opaque bearer bound to a fresh,
server-generated anonymous session:
Send it as Authorization: Bearer <access_token> on the MCP endpoint (or the
REST API). Reuse the token across calls to address the same session; when it
expires, request a new one — which is a new, blank session, since anonymous
tokens have no refresh.
Anonymous access and OAuth coexist — in fact that is the default with an configured. Both token authorities are wired, and the endpoint accepts a bearer from either, so an agent that does not need to act as a user can skip the login flow entirely.
Anonymous sessions carry no user identity, though. With an AuthPlugin
configured, that means:
queue_eventrefuses any handler that is notauth=False, with an actionable error rather than a silent redirect delta.- Protected vars are withheld from the
reflex://state/vars/...reads, and onlyauth=Falsemethods resolve. AuthUserState.current()has no user.
Handler metadata (search_events, reflex://event) stays readable either way.
Without an AuthPlugin this is the only token source.
The endpoint is rate limited per client IP (token_rate_limit, default 10 per
minute) because every grant seeds a server-side session that consumes memory.
Two ways to require a signed-in agent instead:
OAuth 2.1 with an AuthPlugin
When an AuthPlugin is configured, MCPPlugin turns the app into a
spec-compliant OAuth 2.1 Authorization Server + Resource Server for its MCP
endpoint, and federates the human login to your existing OIDC providers. No
extra configuration is required — it is on by default:
Pass MCPPlugin(auth=False) to keep the endpoint anonymous-only even alongside
an AuthPlugin, or auth=True to require the OAuth flow (which fails fast if
no AuthPlugin is configured).
How the flow runs
- An unauthenticated MCP request gets a
401with aWWW-Authenticateheader pointing at the app's protected-resource metadata (RFC 9728). - The client discovers the authorization server (RFC 8414) and registers itself dynamically (RFC 7591).
- The client opens the authorization endpoint in a browser. The app redirects
to a consent page — a normal authenticated Reflex page, so the page guard
bounces an anonymous visitor through your standard
/loginpalette (any configured provider) and back. - The human approves, ticking whichever app scopes they want to grant. The app snapshots the login server-side, mints a single-use authorization code, and completes the exchange with its own opaque, resource-bound access + refresh tokens (PKCE verified) carrying exactly the granted scopes.
Those four endpoints are served at the origin root, ahead of the app's own routes, and clients only ever reach them through the metadata document — so their paths are configurable when one would collide with a page you serve:
Registration defaults to /register-oidc-client rather than the MCP SDK's bare
/register precisely because that path is a common sign-up route the OAuth
endpoint would otherwise shadow. Each path must be distinct and must not live
under the MCP mount, which is checked at wiring time.
The MCP client only ever holds tokens the app issued for its own MCP endpoint; upstream identity-provider tokens never leave the server. Upstream refresh happens server-side, and if the upstream login expires and cannot be refreshed the app-issued token is revoked and the tool returns a clear "re-authenticate" error so the client re-runs the flow.
Because each token is bound to a dedicated server-side session, the entire
enforcement stack applies to agent traffic unchanged: the per-event
AuthMiddleware gate, callable auth= checks, delta filtering, multi-provider
selection, and AuthUserState.current() all behave exactly as they do for a
browser user.
Consent and the confused-deputy problem
A logged-in browser usually skips the identity provider's own consent screen,
so the app's consent page is the human checkpoint that stops a malicious MCP
client from silently acting as the user. It always shows the client's name
alongside the exact redirect host the authorization code will be sent to
(the name comes from dynamic registration and is therefore attacker-controlled;
the host is not), and requires an explicit Approve click. A prior approval
is surfaced as a "you have authorized this client before" hint but never
auto-submits. Consent is recorded per (user, client, redirect URI) for the
audit trail.
The consent route is served with X-Frame-Options: DENY and
frame-ancestors 'none', the primary clickjacking defense, and the approve
handler additionally refuses to run inside a detected iframe.
Customizing the consent page
The page lives at /agent-consent by default (consent_path=, which must not
be under the MCP mount). Replace the component with the same builder contract
as the pages:
The builder is called with a plugin= keyword argument and returns a
component. Render it against MCPConsentState, which exposes client_name,
redirect_uri, redirect_host, requested_scopes, app_scope_options,
app_scope_grants, previously_authorized, error_message, and ready,
plus the approve, deny, and set_app_scope_grant handlers:
App-specific scopes
Approving a client should not hand an agent the user's entire authority. Declare app-specific scopes to make the grant granular:
Each app scope appears on the consent screen as an individually grantable
checkbox — a scope the client requested starts ticked, the others unticked.
The minted access and refresh tokens carry exactly the scopes the human
granted, uniformly across every configured IdP rather than depending on each
IdP's own scope behavior. App scopes are advertised in the OAuth metadata so
clients may request them; add them to default_scopes to have dynamic
registrations request them by default (pre-ticking the boxes).
Use required_scopes to gate the endpoint itself:
A bearer token must carry those scopes to reach the MCP endpoint at all.
Anonymous tokens carry no scopes, so setting required_scopes also disables
anonymous access.
Surface-aware auth checks
Granted scopes are enforced in your own auth= checks. Every auth context —
event, var, and page — carries the surface the request arrived through and the
scopes of the token mediating it:
ctx.surface—"browser"(the normal websocket path),"event_api"(the REST plugin), or"mcp".ctx.token_scopes—Nonefor a browser request (the user's full authority; no token restricts it), or a tuple for an API request: the consent-granted scopes of an OAuth token, or()for an anonymous session.
Use them restrictively: require a scope when the access is token-mediated, and never treat a scope as granting more than the user could do from a browser.
This is deliberately identity-and-surface-based: the same signed-in user keeps full access in the browser while their delegated agent is limited to what they ticked on the consent screen.
Rate limiting
Every MCP call — tools and session-reading resources alike — is counted against
the presenting session token: call_rate_limit per call_rate_window (default
60 per minute), tracked per process. Exceeding it returns a clear retry-after
error. Browser (websocket) events are never rate limited by this mechanism.
Individual handlers can override their own budget where the default is wrong:
An overridden handler is counted in its own per-token bucket; everything else shares the token's default bucket. The two per-IP limiters — anonymous token grants and dynamic client registration — are covered in deploying to production.
Related
- Auto MCP: the endpoint, tools, and resources.
- Secure by default: how
auth=checks work for pages, handlers, fields, and vars. - Deploying to production: TLS requirements, token storage, and reverse proxies.