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/registerwith your client metadata. You get a public client (token_endpoint_auth_methodisnone; registrations that ask for a secret are rejected). Redirect URIs are exact matches,https://for web clients; loopbackhttp://127.0.0.1andhttp://localhostredirects are allowed forapplication_type: nativeand 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 yourclient_id. The server fetches the JSON document at that URL, which must carry the same URL as its ownclient_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,statecode_challengeandcode_challenge_method=S256. PKCE is required;plainis not supported.scope: a space-separated subset of the permissions. Unknown scopes are rejected withinvalid_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.
| Token | Lifetime |
|---|---|
| Access token | 1 hour |
| Refresh token | 30 days, renewed on every use |
| Grant | 1 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.