Skip to content

OAuth 2.1

Let other apps sign in their users with a Bot-Hosting account, like "Sign in with Discord". The app then acts on each user's account, limited to the scopes they approve. Built on OAuth 2.1 + PKCE, so any standard OAuth client works out of the box.

Manage OAuth apps

OAuth or an API key?

Use an API key to drive your own account. Use OAuth when you build an app that acts on other people's accounts, so they log in and consent instead of pasting a key. AI assistants (MCP) like ChatGPT connect through this exact flow.

The flow

1

Create an app

Register a name + redirect URI in your developer settings. You get a public client_id (no secret).

2

Send users to authorize

Redirect to /oauth/authorize with PKCE. The user logs in and approves the scopes.

3

Exchange the code

They come back to your redirect URI with a code. POST it to the token endpoint for an access token.

4

Call the API as them

Send the token as a Bearer to /api/v1. Refresh it in the background so the session lives on.

Endpoints

Public client, PKCE is the proof, no client secret. Discovery is served too, so a standard OAuth library configures itself from the metadata URL alone.

GET /oauth/authorize User consent screen (open in the browser).
POST /api/oauth/token Exchange a code for a token, or refresh one.
POST /api/oauth/register Dynamic Client Registration (RFC 7591), optional.
GET /.well-known/oauth-authorization-server Discovery metadata (auto-config).
GET /.well-known/oauth-protected-resource Resource metadata (used by MCP clients).

1 · Authorize

Generate a PKCE verifier and its code_challenge, then send the user (browser redirect) to the authorize URL. They log in and approve the scopes on a consent screen.

Redirect the user to
https://bot-hosting.net/oauth/authorize
  ?client_id=bhc_your_client_id
  &redirect_uri=https://yourapp.com/callback
  &response_type=code
  &scope=deployments:read+deployments:power
  &code_challenge=BASE64URL(SHA256(verifier))
  &code_challenge_method=S256
  &state=RANDOM

After consent they are sent back to your redirect_uri with ?code=...&state=.... Check that state matches what you sent (CSRF). The code is single-use and expires in 10 min.

2 · Get a token

Exchange the code for an access token, proving PKCE with the original code_verifier. The redirect_uri must match the one you registered.

curl -X POST "https://bot-hosting.net/api/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=AUTH_CODE" \
  -d "redirect_uri=https://yourapp.com/callback" \
  -d "client_id=bhc_your_client_id" \
  -d "code_verifier=PKCE_VERIFIER"
Response 200
{
  "token_type": "Bearer",
  "access_token": "bho_1a2b3c...",
  "refresh_token": "bhr_9z8y7x...",
  "expires_in": 604800,
  "scope": "deployments:read deployments:power"
}

3 · Call the API

Send the bho_ access token as a Bearer to any /api/v1 endpoint the granted scopes allow. The token never grants admin, and always acts as the user who approved it.

curl -H "Authorization: Bearer bho_your_access_token" \
  https://bot-hosting.net/api/v1/deployments

4 · Refresh

Access tokens last 7 days. Before one expires, or on a 401, swap the refresh token for a fresh pair. The user is not prompted again, so the session lasts up to 30 days.

curl -X POST "https://bot-hosting.net/api/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=bhr_current_refresh" \
  -d "client_id=bhc_your_client_id"

Rotation: every refresh returns a new refresh token and revokes the old one. Always overwrite your stored refresh token with the new one, or the next refresh fails. If a refresh returns invalid_grant (expired, revoked, or a stale token), the grant is dead: send the user back through step 1.

Errors

What you get back when something goes wrong, at each step.

On the callback (redirect back to your app)

If the user cancels, they return to your redirect_uri with an error and your state instead of a code:

https://yourapp.com/callback?error=access_denied&state=RANDOM
access_denied The user clicked Deny (or is not allowed). Show a friendly "cancelled" and let them retry.

A bad client_id, redirect_uri, response_type or PKCE is not redirected back (that would be an open redirect). The user sees an error screen on our side and your callback is never called. So if your callback never fires, check those four in your authorize URL.

From the token endpoint (HTTP 400, JSON)

{
  "error": "invalid_grant",
  "error_description": "authorization code is invalid, expired or already used"
}
invalid_request A required parameter is missing (code / redirect_uri / client_id / code_verifier, or refresh_token / client_id).
invalid_client The client_id is unknown. Check it, or that the app was not deleted.
invalid_grant Code expired / already used, redirect_uri mismatch, or the refresh token is invalid or expired. Restart at step 1.
unsupported_grant_type grant_type must be authorization_code or refresh_token.

From an API call (with the access token)

401 Token expired, revoked, or invalid. Refresh it (step 4); if the refresh also fails, restart at step 1.
403 The token lacks the required scope, or the user has no permission on that resource. Request the scope at authorize.
429 Rate limited. Back off and honor the Retry-After header.

Token lifetimes

Authorization code

10 min

Single-use, PKCE-bound.

Access token

7 days

Bearer for /api/v1 calls.

Refresh token

30 days

Rotated on every use.

PKCE (required)

PKCE with S256 is mandatory. Generate a random verifier, derive the challenge, send the challenge at authorize and the verifier at token time.

verifier      = base64url(random(32 bytes))
code_challenge = base64url(sha256(verifier))
# send code_challenge + code_challenge_method=S256 at /oauth/authorize
# send code_verifier at /api/oauth/token

Discovery

Standard metadata endpoints (RFC 8414 + RFC 9728). Most OAuth libraries and MCP clients read these and configure themselves, so you rarely hardcode the URLs above.

Scopes

Request only what you need. The user sees and approves each scope on the consent screen, and the token is limited to those. Same catalog as the REST API.

Deployments

deployments:read List and inspect deployments
deployments:power Start, stop, restart, console
deployments:write Create, edit, delete, resize, move
projects:read List your projects
projects:write Create and delete projects
files:read Browse and download files
files:write Upload, edit, rename, delete files
env:read Read env vars (secrets stay masked)
env:write Set env vars
backups:read List backups
backups:write Create and delete backups
packages:read View installed packages
packages:write Add and remove packages

Account

account:read Read profile and quota
billing:read Read invoices and plan
credits:spend Spend credits to create resources

Templates

templates:read Browse anyone's public templates

Revocation

A grant can end three ways, and each one immediately invalidates the tokens: the user disconnects your app from their dashboard, you disconnect a user from your app in your developer settings, or the refresh token expires after 30 days. Handle invalid_grant / a persistent 401 by restarting the flow at step 1.

Security

  • Store tokens server-side, tied to your user. Never ship them to the browser or a public repo.
  • The access token is a bearer secret: store it server-side, and it is revoked instantly the moment the user disconnects your app (no waiting for expiry).
  • http redirect URIs are accepted for local / IP testing, but use https in production. PKCE protects the code either way.
  • No client secret exists (public client). Anyone can start a flow, but only the redirect URI you registered ever receives a code.
  • OAuth tokens never grant admin, whatever scopes are approved.