thesignup docs
Auth

OAuth

Three-legged OAuth 2.1 + PKCE for third-party apps acting on behalf of users.

OAuth lets a third-party app — including an MCP-speaking AI agent — act on behalf of a thesignup user, with the user's explicit consent and only the scopes they approve.

When to use OAuth vs API keys

Use OAuth when…Use API keys when…
A third-party tool needs access to your users' dataYour own server-to-server scripts
You want scoped, revocable, per-user accessTrusted internal automation
The user should see a consent screenThe user has direct admin access

The flow at a glance

  Agent           Browser           User           thesignup
    │                │               │                │
    │  open auth URL │               │                │
    ├───────────────►│               │                │
    │                │  GET /oauth/authorize?…        │
    │                ├──────────────────────────────► │
    │                │ ◄─────── 200 (consent page) ───┤
    │                │  shows: "My agent wants        │
    │                │  signups:read, participants:read"
    │                │  with [Approve] [Deny]         │
    │                │               │                │
    │                │  user clicks Approve           │
    │                │               ├──────────────► │
    │                │ ◄── 302 to redirect_uri?code=… │
    │                │               │                │
    │ ◄────── browser hits agent's callback URL ──────┤
    │                                                 │
    │  POST /oauth/token (code + code_verifier)       │
    ├────────────────────────────────────────────────►│
    │ ◄──────────────── 200 { access_token, … } ──────┤
    │                                                 │
    │  use access_token as Bearer ► REST or MCP       │
    └────────────────────────────────────────────────►

The whole dance exists so the user, not the agent, decides which scopes and which org the token is good for.

Discovery

Standard discovery endpoints are published — no per-integration setup beyond reading them:

EndpointWhat it returns
/.well-known/oauth-authorization-serverRFC 8414 metadata: authorize URL, token URL, supported scopes, supported flows
/.well-known/oauth-protected-resource-mcpRFC 9728 metadata for the MCP server

A typical client fetches /.well-known/oauth-authorization-server once at startup and caches the URLs.

curl -sS https://thesignup.app/.well-known/oauth-authorization-server | jq
{
  "issuer": "https://thesignup.app",
  "authorization_endpoint": "https://thesignup.app/oauth/authorize",
  "token_endpoint": "https://thesignup.app/oauth/token",
  "registration_endpoint": "https://thesignup.app/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": [
    "authorization_code",
    "refresh_token",
    "urn:ietf:params:oauth:grant-type:device_code"
  ],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": [
    "signups:read", "signups:write",
    "participants:read", "participants:write",
    "register:write", "analytics:read",
    "ai:draft", "reminders:send"
  ],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_post"]
}

Register an app

Dynamic Client Registration (RFC 7591) is supported, so your app can self-register at runtime:

curl -sS -X POST https://thesignup.app/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My agent",
    "redirect_uris": ["https://my-agent.example/oauth/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "token_endpoint_auth_method": "none",
    "scope": "signups:read signups:write"
  }' | jq
{
  "client_id": "cli_01J7K5RXM2YD0FQ9X3GHCK4N8M",
  "client_name": "My agent",
  "redirect_uris": ["https://my-agent.example/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "scope": "signups:read signups:write",
  "token_endpoint_auth_method": "none"
}

Public clients use PKCE and skip the secret. Confidential clients (set token_endpoint_auth_method: "client_secret_post") also receive a client_secret.

Authorization code flow with PKCE — step by step

1. Build a code verifier + challenge

import { randomBytes, createHash } from 'node:crypto';

const verifier = randomBytes(32).toString('base64url'); // 43+ chars
const challenge = createHash('sha256').update(verifier).digest('base64url');

Store verifier somewhere keyed by state so you can pair it with the callback. Discard verifier after the token exchange.

2. Redirect the user to the authorize URL

GET https://thesignup.app/oauth/authorize
  ?response_type=code
  &client_id=cli_01J7K5RXM2YD0FQ9X3GHCK4N8M
  &redirect_uri=https://my-agent.example/oauth/callback
  &scope=signups:read+participants:read
  &code_challenge=<base64url(sha256(verifier))>
  &code_challenge_method=S256
  &state=<random-csrf-token>
  &resource=https://thesignup.app/api/v1

The user sees a consent screen showing:

  • The client_name of the requesting agent
  • The list of requested scopes, each with a plain-English description
  • Which org they're authorizing the agent against (if they belong to more than one, they pick)
  • Two buttons: Approve and Deny

On Approve, the user is redirected to your redirect_uri with a one-time code:

HTTP/2 302
Location: https://my-agent.example/oauth/callback?code=ac_01J7K6N8DQHX5T2B1ME7VK9W3R&state=<csrf>

On Deny, the same redirect happens but with ?error=access_denied.

3. Exchange the code for tokens

curl -sS -X POST https://thesignup.app/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=ac_01J7K6N8DQHX5T2B1ME7VK9W3R" \
  -d "client_id=cli_01J7K5RXM2YD0FQ9X3GHCK4N8M" \
  -d "redirect_uri=https://my-agent.example/oauth/callback" \
  -d "code_verifier=$VERIFIER" | jq
{
  "access_token": "oat_01J7K7P8DQHX5T2B1ME7VK9W3R",
  "refresh_token": "ort_01J7K7P8DQHX5T2B1ME7VK9W3R",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "signups:read participants:read"
}

The scope in the response is what the user actually approved — may be a subset of what you requested.

4. Use the access token

curl -sS -H "Authorization: Bearer oat_01J7K7P8DQHX5T2B1ME7VK9W3R" \
  https://thesignup.app/api/v1/signups

5. Refresh before expiry

curl -sS -X POST https://thesignup.app/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=ort_01J7K7P8DQHX5T2B1ME7VK9W3R" \
  -d "client_id=cli_01J7K5RXM2YD0FQ9X3GHCK4N8M" | jq
{
  "access_token": "oat_01J7K8XYZ…",
  "refresh_token": "ort_01J7K8XYZ…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "signups:read participants:read"
}

Refresh tokens rotate on each use — store the new value and discard the old. Reusing an old refresh token is treated as a token-theft signal and revokes the whole grant.

Audience binding (RFC 8707)

The resource parameter on the authorize request binds the access token to one surface:

Surfaceresource value
REST APIhttps://thesignup.app/api/v1
MCP serverhttps://thesignup.app/mcp/mcp

A token issued for the REST API won't be accepted at the MCP surface, and vice versa — the server rejects mismatched audiences before any handler runs. This blocks a leaked credential from being replayed against a different surface.

If your app needs both surfaces, run two parallel authorizations (different resource, two access tokens).

Scopes

Same catalog as the MCP quickstart. Request the narrowest set that gets the job done — agents and users are both more comfortable approving participants:read than a blanket participants:write.

Error responses

OAuth errors at the token endpoint follow RFC 6749 §5.2:

{
  "error": "invalid_grant",
  "error_description": "Authorization code has expired or already been used."
}

Common codes:

errorWhen
invalid_requestMissing or malformed parameter
invalid_clientUnknown client_id
invalid_grantCode expired, already used, or doesn't match the verifier
unauthorized_clientClient isn't authorized for this grant type
unsupported_grant_typeUnknown grant_type value
invalid_scopeRequested scope isn't in the client's allowed list

REST and MCP responses (not the OAuth endpoints themselves) follow the RFC 7807 problem format.

Device code flow (RFC 8628)

For CLI and headless agents that can't open a browser, the device code flow is supported. The agent prints a short user code; the user enters it on a confirmation URL in their own browser; the agent polls until the user approves.

# 1. Agent requests a code
curl -sS -X POST https://thesignup.app/oauth/device_authorization \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=cli_…" \
  -d "scope=signups:read"
{
  "device_code": "dc_01J7K9…",
  "user_code": "BRSK-9242",
  "verification_uri": "https://thesignup.app/oauth/device",
  "verification_uri_complete": "https://thesignup.app/oauth/device?user_code=BRSK-9242",
  "expires_in": 600,
  "interval": 5
}

The agent displays user_code and verification_uri to the user, then polls the token endpoint every interval seconds with grant_type=urn:ietf:params:oauth:grant-type:device_code. Once the user approves, the polling call returns an access token.

This is the auth mechanism the CLI will use once it ships.

On this page