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. 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, orlocalhost) 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.
-
stateis 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 |
|---|---|
|
A random, URL-safe string between 43 and 128 characters. Keep this secret. It is sent only to the token endpoint. |
|
The |
|
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:
-
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.
-
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.
-
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 |
|
|
Authorization |
|
|
Authorization |
|
|
Authorization |
Unknown |
|
Authorization |
User clicks Deny |
Redirect to |
Token exchange |
Code already used, expired, or |
|
For general API error codes, refer to the API v2 Reference.
Next steps
-
CircleCI API Developer’s Guide - authenticate, paginate results, and make common API calls.
-
API v2 Reference - full endpoint documentation. </content>