Blog

One OAuth server, two audiences: MCP and CLI

Coding agents and the stagely CLI sign in through one OAuth server. Each grant works at one resource, so an agent's token cannot manage members or billing.

The Stagely control plane at https://app.stagely.dev serves two surfaces for programs. Coding agents call the MCP endpoint at /mcp. The stagely CLI calls the JSON API at /api/v1. Both sign in through the same OAuth server, with the same consent page and the same list of connected apps. A token from that server still works at only one of the two surfaces.

One token for both surfaces is too much

The two surfaces do different jobs. The MCP endpoint has eight tools: list projects and stagelets, deploy a pull request, read a stagelet or an operation, and open, reset or delete a stagelet. The JSON API acts with the person’s full role. For an admin or an owner, that includes members, environments, projects and the plan.

A coding agent reads text that it did not write: issues, pull request comments, build logs and web pages. Instructions in that text can change what the agent calls next. If the agent’s token also worked at the API, an owner who connected an agent would give it everything that an owner can do. One misled call could invite a stranger, promote a member or change the plan. So Stagely ties each grant to one surface.

Each grant names one resource

RFC 8707 adds a resource parameter to OAuth. In the authorization request, it names the protected resource that the client wants to reach. In the token request, it names where the client will use the token. Stagely’s authorization server knows two resources:

Stagely binds the grant, and every access token from it, to the resource that the person approved. A request for an unknown resource fails with invalid_target, the error that RFC 8707 defines, and so does a token request that names a resource other than the grant’s. A request without resource gets the MCP endpoint, the narrower of the two.

The MCP endpoint and the API each accept only tokens for their own resource. A token for the other surface gets a 401, the same answer as an expired token. So an agent’s token cannot call the API, and the CLI’s token cannot call MCP tools.

This follows the MCP authorization specification. It requires MCP clients to send resource in both the authorization request and the token request. It requires MCP servers to accept only tokens that were issued for them. Each Stagely resource also publishes its own metadata (RFC 9728) at /.well-known/oauth-protected-resource/mcp and /.well-known/oauth-protected-resource/api/v1, so a client finds the authorization server from either one.

API grants go only to loopback clients

Anyone can register a client, and an https redirect URI can point to any server. Without a limit, a person could approve a web app that looks harmless, and that app would keep an API token on its own server. So the server gives API grants only to clients whose redirect URI uses http on localhost, 127.0.0.1 or ::1. A web client that a person approves gets MCP access at most.

A loopback redirect sends the code back to a program on the same computer as the browser. The stagely CLI works this way. It listens on a random port of 127.0.0.1, registers itself, and names the API as the resource (internal/login/login.go):

authorize := server + "/oauth/authorize?" + url.Values{
	"response_type": {"code"}, "client_id": {clientID}, "redirect_uri": {redirectURI},
	"code_challenge": {challenge(verifier)}, "code_challenge_method": {"S256"}, "state": {state},
	"resource": {server + APIPath},
}.Encode()

stagely login then waits for the redirect, checks state and the iss parameter, and trades the code for tokens. The token request names the same resource.

Registration and PKCE for public clients

Coding agents and the CLI register at /oauth/register with dynamic client registration (RFC 7591). Each registration is a public client: token_endpoint_auth_method is none, and the client gets no secret. A redirect URI must use https, or http on a loopback host. Its host must be in ASCII. So the consent page shows an internationalized host as punycode, not as a look-alike of a known name. The current MCP specification prefers Client ID Metadata Documents and keeps dynamic registration for compatibility. Stagely’s authorization server supports dynamic registration.

A client without a secret needs another way to prove that it started the sign-in. That is PKCE (RFC 7636). The client keeps a random verifier and sends only its SHA-256 hash, the code_challenge, with the authorization request. In the token request, it sends the verifier. The server hashes the verifier and compares the result with the challenge. Stagely accepts only the S256 method and refuses plain. An authorization code works once. If a used code comes back, the code may have leaked, so the server ends the grant that the first use created.

The authorization request opens a consent page on the control plane. If the browser has no session, the person signs in with GitHub first. The page shows the client’s name, with a note that the client chose that name and Stagely did not check it. It shows the person’s GitHub login and the host that the code returns to. It also says what the token allows. For MCP, that is to deploy, open and delete stagelets. For the API, it is to act with the person’s role and manage environments, projects, members, billing and stagelets.

Below that, the person picks one of the accounts they belong to. Each choice shows the person’s role in that account. The grant binds to the chosen account. A token from that grant acts as the person, inside that account only. To the token, every other account looks like one that the person does not belong to.

A role change applies at once. If an owner changes an admin’s role to member, the admin’s CLI token loses the admin actions at its next request. A token also cannot act for the person as a whole: it cannot create an account, accept an invitation, or list and revoke grants. Those actions stay in the console.

When the request has a problem, the page shows an error and does not redirect. Only the person’s decision goes back to the client.

Short tokens and a live grant

An access token lasts 15 minutes, and never longer than its grant. Stagely checks each token against a live grant on every request, so no token stays valid after its grant ends.

A grant lasts at most one hour. Refresh tokens renew the access token inside that hour, but they do not extend the grant. Each refresh replaces the refresh token. An old refresh token that comes back means that two parties hold it, and one of them may be an attacker. So the server then ends the whole grant. After the hour, the person signs in again.

Ending access

Each person sees their grants in the console. Open Settings, then Access. Under Connected apps, click Revoke. The grant and its access tokens are deleted, and the client’s next request gets a 401. A client can also end its own grant at the revocation endpoint (RFC 7009), as stagely logout does with its refresh token. When a person leaves an account or someone removes them, Stagely deletes their grants for that account too.

Stagelets need one more step, because the stagelet gateway (stagely-gateway) serves them, not the control plane. open_stagelet returns a single-use URL that works for 30 seconds. The gateway trades it for a session on that stagelet. The session belongs to the person and to the agent’s grant. It lasts at most one hour, never outlives the grant, and is deleted together with the grant. The gateway confirms its sessions with the control plane every 20 seconds, so the stagelet sessions of a revoked grant stop within about 50 seconds. Fail closed: a 50-second limit on access shows how the gateway does this.

To connect an agent, read Connect a coding agent over MCP and the coding agent guide. Team members, roles and access covers roles and connected apps, and the security model lists the credential at each boundary.

More from the blog