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' data | Your own server-to-server scripts |
| You want scoped, revocable, per-user access | Trusted internal automation |
| The user should see a consent screen | The 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:
| Endpoint | What it returns |
|---|---|
/.well-known/oauth-authorization-server | RFC 8414 metadata: authorize URL, token URL, supported scopes, supported flows |
/.well-known/oauth-protected-resource-mcp | RFC 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/v1The user sees a consent screen showing:
- The
client_nameof 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/signups5. 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:
| Surface | resource value |
|---|---|
| REST API | https://thesignup.app/api/v1 |
| MCP server | https://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:
error | When |
|---|---|
invalid_request | Missing or malformed parameter |
invalid_client | Unknown client_id |
invalid_grant | Code expired, already used, or doesn't match the verifier |
unauthorized_client | Client isn't authorized for this grant type |
unsupported_grant_type | Unknown grant_type value |
invalid_scope | Requested 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.