---
title: "OAuth 2.0 API access with Dynamic Client Registration"
description: "Register OAuth 2.0 clients dynamically and use the authorization code flow with PKCE to obtain a CircleCI personal API token."
doc_version: "unversioned"
last_updated: "2026-08-11"
---

> For the complete documentation index, see [llms.txt](https://circleci.com/docs/llms.txt)

# OAuth 2.0 API access with Dynamic Client Registration

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](https://circleci.com/docs/guides/toolkit/managing-api-tokens/). For general API usage, refer to the [CircleCI API Developer’s Guide](https://circleci.com/docs/guides/toolkit/api-developers-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:

```shell
curl -s https://app.circleci.com/.well-known/oauth-authorization-server | jq .
```

```json
{
  "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](#step-3-start-a-local-redirect-listener) 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.

```shell
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`:

```json
{
  "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](https://datatracker.ietf.org/doc/html/rfc9126) (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](#step-4-open-the-authorization-url) 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.

```shell
# 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:

```python
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:

```shell
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](#step-1-register-your-client) and the PKCE values from [Step 3](#step-3-start-a-local-redirect-listener), then open it in a browser.

```text
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:

```shell
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](#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:

```text
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:

```shell
CODE="5fa2b3c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9"
```

## 5\. Exchange the code for a token

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

```shell
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`:

```json
{
  "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:

```shell
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:

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

See the [API v2 Reference](https://circleci.com/docs/api/v2/) 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](#step-3-start-a-local-redirect-listener) 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](https://circleci.com/docs/guides/toolkit/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](https://circleci.com/docs/api/v2/).

## Next steps

*   [CircleCI API Developer’s Guide](https://circleci.com/docs/guides/toolkit/api-developers-guide/) - authenticate, paginate results, and make common API calls.
    
*   [API v2 Reference](https://circleci.com/docs/api/v2/) - full endpoint documentation. </content>