OAuth for third-party clients

How an MCP host or another third-party client obtains tokens to act on a Borga merchant's behalf.

Borga runs an OAuth 2.1 authorization server at https://auth.borga.is for clients that act on a merchant's behalf with a permission set the merchant chose. Today its only resource is the MCP server at https://mcp.borga.is/mcp. This page is for client authors; merchants connecting an existing assistant need nothing on it.

Merchant backends that act for themselves keep using API keys. The authorization server does not issue them, and the MCP server does not accept them.

Discovery

The MCP server answers every unauthenticated request to /mcp with 401 and a WWW-Authenticate challenge whose resource_metadata parameter points at its protected resource metadata (RFC 9728):

https://mcp.borga.is/.well-known/oauth-protected-resource/mcp

That document names the authorization server, whose own metadata (RFC 8414) is at:

https://auth.borga.is/.well-known/oauth-authorization-server

The MCP server also serves a copy of that document at https://mcp.borga.is/.well-known/oauth-authorization-server for hosts that look for it on the resource's origin. Endpoints are /authorize, /token, /register and /revoke under the issuer. An MCP host that follows the MCP authorization specification needs no configuration at all.

Registering a client

Two ways, neither needing anyone at Borga to set anything up:

  • Dynamic client registration (RFC 7591): POST https://auth.borga.is/register with your client metadata. You get a public client (token_endpoint_auth_method is none; registrations that ask for a secret are rejected). Redirect URIs are exact matches, https:// for web clients; loopback http://127.0.0.1 and http://localhost redirects are allowed for application_type: native and the port is ignored. Registration is limited to 20 per hour per IP address, and every endpoint on the authorization server to 60 requests per minute per IP address, so register once and reuse the client.
  • Client ID metadata document: use an https:// URL as your client_id. The server fetches the JSON document at that URL, which must carry the same URL as its own client_id, and treats it as your registration. Documents are cached between five minutes and a day. Claude Code identifies itself this way.

The consent screen shows the client's name and the host it will redirect to. Clients from well-known hosts are shown as verified; a self-registered client is shown as "registered itself with Borga and has not been verified", and a loopback-only client triggers an extra warning, so choose metadata a merchant will recognise.

The authorization request

GET https://auth.borga.is/authorize with the standard parameters:

  • response_type=code, client_id, redirect_uri, state
  • code_challenge and code_challenge_method=S256. PKCE is required; plain is not supported.
  • scope: a space-separated subset of the permissions. Unknown scopes are rejected with invalid_scope.
  • resource=https://mcp.borga.is/mcp (RFC 8707). Optional; it is the only accepted value and the default.

The person signs in with Kenni.is, picks the merchant and the mode, and may untick any scope you asked for. Money scopes (refunds:write, payments:charge) are never preselected and only owners and admins can grant them, so request only what your client needs and be prepared for a smaller grant than requested. The token response's scope tells you what was granted; account:read is implicit and not listed.

Tokens

POST https://auth.borga.is/token with grant_type=authorization_code, code, redirect_uri, client_id, code_verifier and optionally resource. The response carries an opaque access_token, a refresh_token and the granted scope.

TokenLifetime
Access token1 hour
Refresh token30 days, renewed on every use
Grant1 year from consent, then the person is asked again

Refresh tokens rotate: every grant_type=refresh_token call returns a new one and the old one stops working. Presenting a refresh token a second time revokes the whole grant, as does reusing an authorization code, so persist the latest token before you use it. A refresh that fails with invalid_grant means the grant is gone (revoked by the merchant, the membership ended, or reuse was detected); send the person through authorization again.

Access tokens are bound to one merchant and one mode and are validated by Borga on every request, so a revocation is refused on the next call that touches merchant data (the MCP server may list tools from a cache for up to a minute). Send them to the MCP server as Authorization: Bearer. The API's /v1 routes accept them too, under the same audience, membership and scope checks, but the MCP server is what they are issued for.

Revocation

POST https://auth.borga.is/revoke with token (either kind) and client_id revokes the whole grant and always answers 200. Merchants can also revoke a connection from the dashboard at any time; do not assume a token is valid because it has not expired.

Errors

Errors from the authorization server use the OAuth error shape (error, error_description) with the standard codes: invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope, invalid_target. On the MCP server, a missing or expired token is 401 invalid_token with a WWW-Authenticate challenge, and a tool the connection has no permission for is simply not listed. If the API still refuses a call for a missing scope, the result is a tool error naming the scope rather than a 401, so a permission problem does not restart sign-in.