OAuth 2.1 Server

Overview

The OAuth 2.1 server turns the application you host on Stormkit into an OAuth authorization server. External clients — most commonly MCP connectors such as Claude (claude.ai / claude.com) and ChatGPT (chatgpt.com), or native/CLI clients like Claude Code — register with your app and obtain tokens that let them call your app's MCP endpoint as your end users.

This is the opposite role from the Google / X provider logins described in Authentication. There, your app is a client of an external identity provider. Here, your app is the authorization server that other clients connect to.

The OAuth server is layered on top of Stormkit Auth (SkAuth): it reuses SkAuth's end-user identities and its signing secret. SkAuth must be enabled for the OAuth server to work.

Requirements

  • Available on self-hosted installations only.
  • Stormkit Auth must be enabled (which in turn requires a database attached to the environment). Identities and the token-signing secret come from SkAuth.

How it works

All endpoints are served from your app's own hosting domain under the /_stormkit/oauth path prefix. Stormkit intercepts these paths before your deployment is served — there is nothing to install in your application.

Endpoints

Endpoint Description
POST /_stormkit/oauth/register RFC 7591 Dynamic Client Registration. Clients self-register as public clients; PKCE is required. A client that asks for grant_types: ["authorization_code", "refresh_token"] is granted both; unsupported grant types are dropped.
GET /_stormkit/oauth/authorize Authorization request (RFC 6749 + PKCE RFC 7636). Renders your app's sign-in page when the deployment ships one. Otherwise, if the user has no readable session, the server delegates to your app's configured Login URL (with a loop-guarded return_to param) rather than showing a Stormkit login page.
POST /_stormkit/oauth/authorize Consent / grant submission. Issues the authorization code. Answers JSON to a fetch call, and redirects to the client when submitted as an HTML form.
POST /_stormkit/oauth/token Exchanges the authorization code + PKCE code_verifier for an access token, and — when the offline_access scope was requested — a rotating refresh token.
POST /_stormkit/oauth/revoke RFC 7009 token revocation. Deletes a refresh token. Always returns 200, even for unknown tokens (per RFC 7009 §2.2, to prevent probing).

Tokens

Token How it works
Access token A stateless HS256 JWT signed with the environment's SkAuth secret. The edge validates it on every request. It carries an audience (aud) bound to the resource (RFC 8707) — your app's origin plus the MCP resource path. Access tokens are not individually revocable; they expire on their own after the access token TTL (oauthAccessTokenTtl), which falls back to the session TTL when unset. Keep it short (a few hours) and let clients renew with refresh tokens. Turning the OAuth server off stops all issued access tokens from authenticating immediately.
Refresh token Stored server-side (Redis, SHA-256 keyed), rotates on each use, and is only issued when the offline_access scope is requested. Refresh tokens can be revoked via /revoke.

Discovery

The server publishes standard /.well-known/ metadata so clients can discover it automatically:

  • OAuth Authorization Server metadata.
  • Protected Resource metadata (RFC 9728), including a per-path variant at /.well-known/oauth-protected-resource/<path> matching your MCP resource path.

The /revoke endpoint is advertised as revocation_endpoint in the metadata. When an unauthenticated request hits the MCP endpoint, the auth challenge points the client at these documents.

Redirect URI validation

Redirect validation for the OAuth server does not use SkAuth's Allowed origins list. Instead:

  • A curated set of connector presets is trusted automatically when the OAuth server is enabled: claude.ai, claude.com, and chatgpt.com. No per-origin configuration is needed for these clients.
  • Native / CLI clients that use RFC 8252 loopback redirects (localhost on an ephemeral port) are supported only when the Allow loopback toggle is on. Loopback redirects are matched on scheme + path, ignoring the port.

Configuration

Configure the OAuth server under Environment > Authentication.

  1. Enable Stormkit Auth first. This requires a database attached to the environment.
  2. Turn on the OAuth server.
  3. Set the MCP resource path (e.g. /mcp). This must exactly equal the path in the connector URL your users enter — a mismatch makes connectors fail silently.
  4. Optionally enable Allow loopback to support native / CLI clients.
  5. Set a Login URL — your app's own login page. /authorize delegates unauthenticated users here. Skip this if your deployment ships a custom sign-in page.

Remember to Save after changing these settings.

Configuring via the API

The same settings can be set programmatically with POST /v1/auth/config using a user- or environment-scoped API key, or via the configure_auth MCP tool. The relevant fields are:

Field Meaning
oauthServerEnabled Enable the OAuth server.
oauthResourcePath The MCP resource path (e.g. /mcp).
oauthAllowLoopback Allow RFC 8252 loopback redirects.
oauthAccessTokenTtl Access-token lifetime in minutes, independent of the session tokenTtl. 0 uses tokenTtl.
loginUrl Your app's login page for /authorize delegation.

See the Authentication docs for the full field list.

Custom sign-in page

Instead of building a login page that handles return_to itself, ship a file named _stormkit/oauth.html at the root of your build output. When it exists, /_stormkit/oauth/authorize renders it for every visitor — signed in or not, and when the request is invalid — in place of the built-in consent screen and the Login URL redirect.

The file is a Go html/template, the same syntax as data loaders: values are escaped for the context they land in, and default provides a fallback value ({{.client_name | default "An app"}}). Stormkit reserves the _stormkit/ folder of your build output: files in it are never served at their own path, so the template's source does not leak.

Field Meaning
.client_name The client's registered name, else its client_id. Self-reported by the client.
.client_id The client's id.
.redirect_host Where the authorization goes back to, e.g. claude.ai. This is the value Stormkit verifies.
.scopes The requested scopes, each with .name and a readable .label.
.signed_in Whether the visitor has a session.
.user.id, .user.email The signed-in user. Unset when signed out.
.continue_url Completes the authorization. POST a form to it; a link will not work. Unset when signed out.
.cancel_url Returns a denial to the client. A plain link.
.login_url Your Login URL with a return_to back here, when one is configured and the visitor is signed out.
.sign_in.google, .sign_in.x Starts sign-in with that provider and comes back here. Set only when the provider is enabled.
.sign_in.magic_link POST a form with an email field to it to send a magic link that comes back here. Set only when magic links are enabled.
.sign_in.link_sent The visitor just asked for a magic link.
.sign_in.error Why the last sign-in attempt failed: an invalid email, an expired or used link, or a cancelled provider sign-in.
.error Why the request cannot go on. When it is set, no other field is.
<!doctype html>
<html>
  <body>
    {{if .error}}
      <p>{{.error}}</p>
    {{else if .signed_in}}
      <h1>Connect {{.client_name}} to your account</h1>
      <p>Signed in as {{.user.email}}. {{.client_name}} will be able to:</p>
      <ul>{{range .scopes}}<li>{{.label}}</li>{{end}}</ul>
      <form method="post" action="{{.continue_url}}">
        <button type="submit">Continue</button>
      </form>
      <a href="{{.cancel_url}}">Cancel</a>
    {{else}}
      <h1>Sign in to connect {{.client_name}}</h1>
      {{with .sign_in.error}}<p>{{.}}</p>{{end}}
      {{with .sign_in.google}}<a href="{{.}}">Continue with Google</a>{{end}}
      {{if .sign_in.link_sent}}
        <p>Check your inbox for a sign-in link.</p>
      {{else}}{{with .sign_in.magic_link}}
        <form method="post" action="{{.}}">
          <input type="email" name="email" required>
          <button type="submit">Email me a link</button>
        </form>
      {{end}}{{end}}
    {{end}}
  </body>
</html>

Sign-in started from this page always comes back to it. The pending authorization is carried in the magic link and in the Google / X sign-in itself, not in the browser. So a magic link opened on another device signs that device in and continues the authorization there. Whether the client then finishes the connection depends on the client.

The page is served with X-Frame-Options: DENY, Cache-Control: no-store and Referrer-Policy: same-origin. Keep the referrer policy: a form posted under no-referrer is sent with Origin: null and rejected as cross-origin.

How a connector connects

  1. The user enters your app's MCP URL (e.g. https://app.example.com/mcp) into Claude or ChatGPT.
  2. The client discovers your OAuth server via the /.well-known/ metadata.
  3. The client self-registers at /_stormkit/oauth/register.
  4. The user is sent to /_stormkit/oauth/authorize.
  5. If they are not signed in, the server delegates them to your app's Login URL; after login they return to /authorize.
  6. The user consents, and the server issues an authorization code.
  7. The client exchanges the code + PKCE code_verifier at /_stormkit/oauth/token for an access token (and a refresh token if offline_access was requested).
  8. The client calls your MCP endpoint with the access token as a bearer token; the edge validates it on every request.

Troubleshooting

  • The connector fails silently: the MCP resource path must exactly match the path in the URL the user entered (e.g. /mcp). A mismatch is the most common cause.
  • Nothing works even though the path is right: make sure Stormkit Auth is enabled — the OAuth server depends on it for identities and the signing secret.
  • A native / CLI client can't complete the flow: enable the Allow loopback toggle. Loopback redirects are only trusted when it is on.
  • A custom sign-in page shows "could not be rendered": the template has a syntax error, or reads a field of a value that is not set (use {{with}}).
  • Users get stuck in a login loop: set the Login URL to your app's login page so /authorize can delegate unauthenticated users to it.