OAuth for an MCP server in .NET: PKCE and consent
How an MCP server gets per-user OAuth with PKCE: a small authorization server, a consent screen, scoped grants and connections that revoke cleanly.
An MCP server that only answers to one shared secret is easy to build and hard to defend. Every AI app that connects acts as the same identity, nobody can say which client did what, and revoking one app means rotating the secret for all of them. The Model Context Protocol specification asks for something better: each person authorizes each client, through OAuth, and the server acts as that person.
That requirement is simple to state and awkward to meet in an existing product. This post describes how Slicekit meets it, which parts were small, and where the sharp edges are. The setup steps live in the MCP docs; this is the reasoning behind them.
Why a small authorization server, and not a library
Slicekit already has users, passwords, TOTP and passkeys. What it did not have was an OAuth authorization server, because it only ever issued its own session cookies. The MCP clients need one: they discover it, register themselves, send the person to a consent page and exchange a code for tokens.
The obvious move is to bolt on a full OpenID Connect server. Slicekit did not. The sign-in and consent steps
already exist as screens in the SPA, with password, authenticator and passkey support, so a second login UI
would duplicate the most security-sensitive code in the product. What was missing was only the protocol layer
around those screens: discovery, client registration, the authorization code flow with PKCE and the token
endpoint. That is a small OAuth 2.1 server in Mcp/Authorization/, and the product’s own sign-in does the
hard part.
The trade is honest. You get little code and one login surface. You give up the breadth of a full identity server, so if you need arbitrary third parties, custom grant types or federation of other identity providers into the MCP flow, this is the wrong shape.
PKCE is not optional here
MCP clients are public clients. A desktop app or an editor cannot keep a client secret, and the redirect can land on a localhost URL that another process on the machine could also listen on. PKCE (Proof Key for Code Exchange) is what makes a stolen authorization code useless: the client commits to a hash of a secret before the redirect and proves it knows the secret when it redeems the code. The server rejects the redemption without it.
Treat that as a rule of the design, not a setting. Slicekit rejects an authorization request that does not use PKCE with the S256 method, and checks the verifier again when the code is redeemed, so there is no configuration to get wrong in production.
A connection is a session
The most useful decision was not to invent a second kind of credential. When a client redeems an authorization code, the server creates a refresh-token row with the client’s id and name attached. From then on a connected app is an ordinary session, and everything sessions already do applies without new code:
- Refresh tokens rotate, and the existing theft detection applies.
- Revoking a connection revokes the whole family of refresh rows, because the app rotates its row on every refresh and the one on screen may already be superseded.
- Changing a password revokes every connection along with the browser sessions.
- The data export lists connected apps, and deleting the account removes them.
- Access tokens already issued stop working when the session is revoked, because the session id is a claim that the server checks against a revocation cache.
Browser sessions and connected apps are listed separately in the settings, but they are the same machinery.
The token carries identity and scope, never permissions
This is the part worth copying. The access token says who the person is and which OAuth scope the client was
granted. It does not list permissions. On every request to /mcp the server works out what the client may
do, as the intersection of four things:
- The person’s current permissions. If an admin removes a permission from the user, the connected app loses it on its next request.
- A delegation ceiling. Only permissions marked delegable can ever reach a connected app, the same ceiling API keys have. Admin permissions never do.
- The grant. What the person chose on the consent screen, stored per connection.
- The token’s scope.
slicekit.readallows only read-only permissions;slicekit.writeallows changes within the other three limits.
Because the token holds no permission list, narrowing a grant takes effect on the next request, with no token to revoke and nothing stale left in flight. The cost is a lookup per request, which the server caches for two minutes per session and evicts the moment a grant is edited.
The consent screen offers presets (read only, or read and make changes when the client asked for write scope) and a custom list. A read-only preset stores no permissions at all and is expanded from the current tools on each request, so a tool added later is included in existing read-only connections without anyone editing them. A custom grant does not pick up new tools, which is the point of choosing one.
Keeping the two bearer schemes apart
Slicekit signs session tokens and connected-app tokens with the same key, which is convenient and dangerous. Without separation, a token minted for the web app could be presented to the MCP endpoint, or the reverse.
The tokens are kept apart three ways. A connected-app token has the MCP resource URL as its audience, the
at+jwt type, and a client_id claim. The MCP scheme requires all three, and the session scheme refuses any
token that has a client_id. The server also refuses to start if the session audience equals the MCP resource
URL. None of these is exotic, but all three are needed.
Approval is a security action
Letting an application act as a person is a bigger grant than signing in. Approval therefore requires a confirmed email, a real signed-in browser session, and no impersonation: an administrator acting as someone cannot create a connection that outlives the impersonation. If the person signed in a while ago, approval also asks for step-up verification, the same check used before creating an API key.
Refresh follows the same logic. A client may ask for a narrower scope than it holds, and the narrower scope is
stored, so later refreshes cannot quietly regain write access. A request for a wider, unknown or empty scope
returns invalid_scope and leaves the refresh credential usable, so a client with a bug can correct itself
and retry instead of being locked out.
What it does not solve
Three limits are worth stating.
- Client names are labels. An unverified client registers itself and chooses its own display name. The audit trail records both the name and the client id, and the id is the one to trust.
- Hosted clients need a public server. A localhost address works for clients on your machine; hosted
clients need a reachable origin, and your proxy must forward
/mcp,/.well-known/*and/oauth/*as well as the API. - API keys are an opt-in escape hatch. Headless clients such as CI can use an API key instead, but a key skips the consent screen and is not listed under connected apps, so it is off by default.
If you are building this yourself, the pieces to protect are the same whatever the stack: PKCE on every code redemption, a token that carries scope instead of permissions, a connection that is a revocable session, and an approval step that is harder than a login. The MCP docs cover the configuration, and the authentication docs cover the session model underneath.
Explore the documentation
Every how-to that ships with the template, public and searchable.