Documentation structure for LLMs (llms.txt)

OAuth 2.0 API access with Dynamic Client Registration

Cloud

CircleCI supports the OAuth 2.0 authorization code flow with mandatory PKCE (Proof Key for Code Exchange) and Dynamic Client Registration (DCR). Use this flow to let a tool, CLI, or integration get a personal API token on behalf of a user. It avoids a pre-shared client secret or manual token creation in the CircleCI web app. The resulting token is a standard 90-day CircleCI personal API token, and works with all existing API v2 endpoints.

For background on CircleCI API tokens, refer to Managing API Tokens. For general API usage, refer to the CircleCI API Developer’s Guide.

Overview

The OAuth flow consists of four high-level stages:

Register

Your tool registers itself once as an OAuth client using the Dynamic Client Registration endpoint. No authentication is required, and no client secret is issued.

Authorize

A user opens an authorization URL in a browser, selects an access level, and clicks Allow. CircleCI redirects to a loopback address with a short-lived authorization code.

Exchange

Your tool exchanges the code, plus the PKCE verifier, for a 90-day personal API token.

Use

The token is passed as a standard Bearer header on any CircleCI API v2 call.

Access levels

During the authorization consent screen, the user chooses the access level to grant the requesting tool.

Access level Permissions

Read

Read-only access to all resources the authorizing user can read.

Write

Read and write access to all resources the authorizing user can read and write.

Admin

Full access to all permissions the authorizing user holds, which may include org management and billing settings for organizations where the user is an admin.

These scopes restrict what the token can do. They cannot grant more access than the authorizing user already holds in CircleCI. A user with read-only org permissions who selects Admin, for example, receives a token scoped to their actual permissions, not elevated ones.

SSO organizations

If the authorizing user belongs to one or more SSO-enabled organizations, the consent screen includes an SSO organizations section. The user can authorize individual SSO orgs to be included in the token’s scope. SSO org access can also be managed later from the OAuth Clients page in user settings.

Discovery document

All OAuth endpoints are published in a standard discovery document:

curl -s https://app.circleci.com/.well-known/oauth-authorization-server | jq .
{
  "issuer": "https://app.circleci.com",
  "authorization_endpoint": "https://app.circleci.com/oauth/authorize",
  "token_endpoint": "https://app.circleci.com/oauth/token",
  "pushed_authorization_request_endpoint": "https://app.circleci.com/oauth/par",
  "registration_endpoint": "https://app.circleci.com/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"]
}

Prerequisites

  • The redirect URI used during registration and authorization must be a loopback address (127.0.0.1, ::1, or localhost) with a port in the range 1025-65535, and no query parameters. This restriction ensures the authorization code is delivered only to the local machine running the tool.

  • PKCE with S256 is mandatory. Plain code challenges are not accepted.

  • state is required on every authorization request. Omitting it returns a 400 error.

  • Your tool needs a way to open a browser and briefly listen for an HTTP redirect on localhost. The Python listener in Step 3 provides a minimal reference implementation.

1. Register your client

Perform client registration once per tool. Send a POST request to the registration endpoint with a JSON body describing your client. No authentication header is required.

The redirect_uri in this step must match the redirect_uri you use later in the authorization URL and token exchange.
curl -s -X POST https://app.circleci.com/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My Tool",
    "redirect_uris": ["http://127.0.0.1:9090/callback"],
    "grant_types": ["authorization_code"],
    "response_types": ["code"]
  }' | jq .

A successful registration returns 201 Created:

{
  "client_id": "019847ab-1234-7def-89ab-cdef01234567",
  "client_id_issued_at": 1722124800,
  "client_name": "My Tool",
  "redirect_uris": ["http://127.0.0.1:9090/callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

Save the client_id. Use it for every subsequent authorization request. Because no client secret is issued, this value does not need to be treated as sensitive. PKCE protects the flow instead.

Register only once per tool. Store the client_id alongside your tool’s configuration, and reuse it for all future authorizations. Re-registering creates a new, separate client.

Advanced: pushed authorization requests

CircleCI also supports RFC 9126 Pushed Authorization Requests (PAR) via POST /oauth/par. PAR sends the authorization parameters directly to the server before the browser redirect, which provides an additional layer of request integrity. It is optional. The standard front-channel authorization URL described in Step 4 is validated with the same rules.

2. Understand PKCE values

PKCE replaces the client secret. Before each authorization request, your tool must generate three values:

Value Description

code_verifier

A random, URL-safe string between 43 and 128 characters. Keep this secret. It is sent only to the token endpoint.

code_challenge

The code_challenge is base64url(sha256(code_verifier)), sent in the authorization URL so the server can verify the exchange later.

state

A random value used to prevent CSRF attacks. Verify that the state returned in the redirect matches what you sent.

The following shell commands illustrate how these values are derived. In practice, your code, or the Python listener in the next step, generates them automatically.

# 43-128 character URL-safe random string
CODE_VERIFIER=$(openssl rand -base64 48 | tr -d '=\n' | tr '+/' '-_')

# S256 challenge = base64url(sha256(verifier))
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | \
  openssl base64 | tr -d '=\n' | tr '+/' '-_')

# Random state for CSRF protection
STATE=$(openssl rand -base64 16 | tr -d '=\n' | tr '+/' '-_')

3. Start a local redirect listener

Before opening the authorization URL, start a process that receives the redirect from CircleCI. The listener below generates the PKCE values, prints them, then waits for CircleCI to redirect the browser to http://127.0.0.1:9090/callback with the authorization code.

Run this in a separate terminal:

python3 -c "
import http.server, urllib.parse, threading, secrets, hashlib, base64

code_verifier = secrets.token_urlsafe(48)
code_challenge = base64.urlsafe_b64encode(
    hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b'=').decode()
state = secrets.token_urlsafe(16)

print('verifier :', code_verifier)
print('challenge:', code_challenge)
print('state    :', state)
print()

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
        code  = params.get('code',  [''])[0]
        state = params.get('state', [''])[0]
        error = params.get('error', [''])[0]
        print()
        if error:
            print('ERROR:', error)
            print('description:', params.get('error_description', [''])[0])
        else:
            print('CODE :', code)
            print('STATE:', state)
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b'You can close this tab.')
        threading.Thread(target=self.server.shutdown, daemon=True).start()
    def log_message(self, *args):
        pass

with http.server.HTTPServer(('127.0.0.1', 9090), Handler) as s:
    print('Listening on http://127.0.0.1:9090 ...')
    s.serve_forever()
"

Once the listener is running, copy the printed values into your other terminal:

CODE_VERIFIER="<paste verifier>"
CODE_CHALLENGE="<paste challenge>"
STATE="<paste state>"

4. Open the authorization URL

Construct the authorization URL using your client_id from Step 1 and the PKCE values from Step 3, then open it in a browser.

https://app.circleci.com/oauth/authorize?
  response_type=code
  &client_id=019847ab-1234-7def-89ab-cdef01234567
  &redirect_uri=http%3A%2F%2F127.0.0.1%3A9090%2Fcallback
  &code_challenge=<CODE_CHALLENGE>
  &code_challenge_method=S256
  &state=<STATE>

To build and print the full URL in one shell command:

CLIENT_ID="019847ab-1234-7def-89ab-cdef01234567"
REDIRECT_URI="http://127.0.0.1:9090/callback"

printf 'https://app.circleci.com/oauth/authorize?response_type=code&client_id=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256&state=%s\n' \
  "$CLIENT_ID" \
  "$(python3 -c "import urllib.parse; print(urllib.parse.quote('$REDIRECT_URI', safe=''))")" \
  "$CODE_CHALLENGE" \
  "$STATE"

Open the printed URL in a browser. If you are not already logged in to CircleCI, you are redirected to the login page first, then returned to the consent screen.

On the consent screen:

  1. Select the access level to grant the tool: Read, Write, or Admin. See Access levels for a description of each. Choose the minimum level your tool requires.

  2. If you belong to SSO-enabled organizations, the SSO organizations section appears below. Select Authorize next to any orgs you want to include in the token’s scope.

  3. Select Allow to approve, or select Deny to cancel the flow.

CircleCI redirects the browser to your listener, which prints:

CODE : 5fa2b3c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9
STATE: rAnDoMsTaTe12345
Before proceeding, verify that the STATE value printed by the listener matches the STATE value you sent in the authorization URL. A mismatch indicates a possible CSRF attack. If the values do not match, abort the flow.

Copy the code into a shell variable in your working terminal:

CODE="5fa2b3c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9"

5. Exchange the code for a token

The authorization code is single-use and short-lived. Exchange it before it expires.

curl -s -X POST https://app.circleci.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$CODE" \
  -d "client_id=$CLIENT_ID" \
  -d "redirect_uri=$REDIRECT_URI" \
  -d "code_verifier=$CODE_VERIFIER" | jq .

A successful exchange returns 200 OK:

{
  "access_token": "ccipat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "token_type": "Bearer",
  "expires_in": 7776000
}

expires_in is 7,776,000 seconds, or 90 days. This flow does not issue refresh tokens. When the token expires, run the authorization flow again to get a new token.

Store the access_token securely. Treat it with the same care as a password.

6. Use the token

The access_token is a standard CircleCI personal API token. Pass it as a Bearer token on any API v2 call:

TOKEN="ccipat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

curl -s https://circleci.com/api/v2/me \
  -H "Authorization: Bearer $TOKEN" | jq .

It is also accepted as a Circle-Token header for compatibility with existing integrations:

curl -s https://circleci.com/api/v2/me \
  -H "Circle-Token: $TOKEN" | jq .

See the API v2 Reference for the full list of available endpoints.

Token lifecycle

Expiry

Tokens expire after 90 days (7,776,000 seconds). This flow does not issue refresh tokens. Re-run the authorization flow from Step 3 to get a new token when yours expires.

Rotation

Re-running the authorization flow for an existing client_id and user pairing atomically revokes the previous token and issues a new one. You do not need to manually delete the old token before starting a new flow. This behavior makes scheduled rotation straightforward: run the flow on a cadence shorter than 90 days to maintain uninterrupted access.

Manual revocation

Tokens issued through OAuth appear on the user’s Personal API Tokens page, and can be deleted from there at any time. See Managing API Tokens for details.

Error reference

The following table describes the error responses you may encounter at each stage of the flow.

Stage Scenario Response

Registration

redirect_uri is not a loopback address, or port is out of range 1025-65535

400 {"message": "invalid redirect_uri"}

Authorization

state parameter omitted

400 {"message": "state is required"}

Authorization

code_challenge_method is not S256

400 {"message": "code_challenge_method must be S256"}

Authorization

Unknown client_id

400 {"message": "unknown client_id"}

Authorization

User clicks Deny

Redirect to redirect_uri with ?error=access_denied&error_description=the+user+denied+the+request&state=…​

Token exchange

Code already used, expired, or code_verifier does not match

400 {"error": "invalid_grant"} (RFC 6749 standard error body)

For general API error codes, refer to the API v2 Reference.

Next steps