# OAuth for third-party clients

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

Source: https://docs.borga.is/oauth

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](/mcp) 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](/authentication). 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](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) 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](/mcp#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`.

| 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.
