Sign in with Cluster
Register OIDC clients so end users can sign in to your apps with their Cluster account.
Sign in with Cluster
Add "Sign in with Cluster" to your app via standard OpenID Connect — end users authenticate with their Cluster account and your app receives an id_token containing their identifier and profile claims. The CLI registers and manages the OAuth 2.0 clients; the identity provider itself runs at https://accounts.cluster.app.
OIDC clients are top-level objects scoped to an organization: a registered client belongs to the org and is visible to every member. Each client carries its own redirect URIs, scopes, grant types, and a client_secret (issued once at create time — never recoverable later).
The Flow
# 1. Register the client (returns the one-time client_secret)
ccp oidc create --name myapp --redirect-uri https://myapp.com/auth/callback
# 2. Use the issuer + client_id + client_secret in your app's OIDC library
# (issuer = https://accounts.cluster.app; client_id = the hydra_client_id from step 1)
# 3. Users hit /auth/login in your app → get redirected to accounts.cluster.app
# → sign in → redirected back with an authorization code → exchange for tokensThe client_secret prints once at create time. If you lose it, rotate it with ccp oidc rotate-secret.
Register a Client
ccp oidc create --name myapp --redirect-uri https://myapp.com/auth/callback
# ✓ Created OIDC client myapp
# id: oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
#
# Issuer: https://accounts.cluster.app
# Client ID: b6e3f2a1-90b5-4532-9139-13baa05ad0b3
# Client Secret: zKv3rT9pQ2mN8wL4xY7bF1jA5sH6gE0d
#
# ⚠ Save the secret now — it cannot be recovered later.
# Rotate it any time with `ccp oidc rotate-secret <id>`.--name is unique per organization (1–128 chars). Two clients in the same org cannot share a name; two different orgs can each have a client named myapp.
--redirect-uri is repeatable — pass once per allowed callback URL:
ccp oidc create --name myapp \
--redirect-uri https://myapp.com/auth/callback \
--redirect-uri http://localhost:3000/auth/callbackRedirect URIs must be https://* or http://localhost(:port) / http://127.0.0.1(:port). Fragments, wildcards, and userinfo are rejected. Matching is exact: https://myapp.com/cb does not match https://myapp.com/cb/ or https://myapp.com/cb?foo=bar.
The org is resolved from cluster.toml, the --org-id flag, or — when ambiguous — an interactive picker:
ccp oidc create --name myapp --redirect-uri https://myapp.com/cb --org-id org_abc123Two IDs to Track
create prints two UUIDs, and they're for different audiences. Don't paste the wrong one — the labels in the output are the disambiguator:
| Output label | Shape | Goes where |
|---|---|---|
id: | oc-<uuid> | The CLI id. Use with ccp oidc info, rotate-secret, destroy. Your app never sees this. |
Client ID: | bare <uuid> (no prefix) | The OAuth client_id your OIDC library expects. This is what goes in CLUSTER_CLIENT_ID in your app's env. |
Both are real UUIDs. The oc- prefix on the CLI id keeps the two distinguishable when you're copy-pasting — paste whichever label matched what you're filling in.
Optional Flags
| Flag | Default | When to set it |
|---|---|---|
--subject-type {public,pairwise} | public | Pass pairwise only if you need per-RP pseudonymous sub claims. public is correct for most apps. |
--first-party | off | Marks the client as first-party; the consent screen auto-accepts. Third-party (default) shows users a "myapp wants permission to..." prompt. |
--scope <scope> (repeatable) | openid email profile offline_access | Override the default scope list. |
--grant-type <type> (repeatable) | authorization_code refresh_token | Override the default grant list. |
The defaults are right for a standard authorization-code app with refresh tokens. Don't change them unless you have a specific reason — empty --scope / --grant-type lists let the API fill in the defaults; passing an empty list explicitly is not the same as omitting the flag.
When you run ccp oidc create from inside a project directory (one with .cluster/config.json), the new client also auto-links to that project — see Link the Client to a Project for what that unlocks. Pass --no-link to opt out (the client is created the same way, just without the project association).
Link the Client to a Project
OIDC clients enforce strict redirect-URI matching (no wildcards, no patterns). That means every deploy URL your users sign in from has to be pre-registered — manually tracking that list breaks the moment your project gets a new prod URL, a custom domain, or a preview environment.
Linking solves it. When a project is linked to an OIDC client, ccp deploy, ccp promote, and ccp domain link automatically sync the client's redirect URIs with whatever hosts the project actually serves. No env vars; no manual list management.
# From inside a project directory:
ccp oidc link oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
# ✓ Linked project to OIDC client myapp
# id: oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
# ✓ Registered https://myapp.clusterbase.dev/auth/callback with OIDC clientlink validates that the client belongs to the same organization as the project (cross-org linking is rejected before any state change is written). It then writes oidc_client_id to .cluster/config.json and backfills by default: queries the project's production deployment URL plus every custom-linked domain and registers https://<host>/auth/callback for each in one atomic PATCH. Pass --no-backfill to defer registration to the next ccp deploy.
ccp oidc create does the same backfill-less link automatically when run inside a project dir (so the typical "create + use here" flow is one command). The --redirect-uri you pass to create is whatever you already needed for local dev — the prod URL gets registered on the first ccp deploy --prod.
Auto-register on Deploy
Once the project is linked, ccp deploy --prod and ccp promote register the production deploy URL, and ccp domain link registers any newly-attached custom domain:
ccp deploy --prod
# ◼ Function deployed!
# › https://myapp.clusterbase.dev
# ✓ Registered https://myapp.clusterbase.dev/auth/callback with OIDC client
ccp domain link app.example.com
# ✓ Domain app.example.com linked
# ✓ TLS certificate will be issued automatically
# ✓ Registered https://app.example.com/auth/callback with OIDC clientBehavior at a glance:
- Idempotent. If the URI is already in
redirect_uris, nothing happens — no print, no PATCH. - Production only. Preview deploys (without
--prod) are NOT auto-registered. Each preview produces a unique<deployment-id>.clusterbase.devhost that would accumulate forever in the OIDC client; the practical client-config cap would be hit within weeks of active CI use. Useccp oidc update --add-redirect-uriif you specifically need OIDC sign-in on a preview URL. - Non-fatal. If the sync fails (network blip, lost session, client deleted), the deploy itself still succeeds. You get a
⚠ Could not auto-register OIDC redirect URIline with a copy-pasteable retry invocation. No redeploy needed to recover. - Project-scoped on domain link.
ccp domain link foo.com --function fn-OTHERonly auto-registers iffn-OTHERmatches the cwd project's function. Registering on the wrong client across projects is silently skipped. - Manual unlink.
ccp domain unlinkdoes NOT auto-deregister — the CLI can't tell which project the domain was previously attached to without an extra round-trip. Remove the URI manually viaccp oidc update --remove-redirect-uriif needed.
Custom Callback Path
The default callback path is /auth/callback. If your app uses a different path (e.g. /api/auth/callback, /oauth/callback), edit oidc_callback_path in .cluster/config.json:
{
"function_id": "fn-...",
"organization_id": "org_...",
"oidc_client_id": "oc-...",
"oidc_callback_path": "/api/auth/callback"
}The setting applies to every subsequent auto-register (deploy, promote, domain link). Existing URIs on the client are not migrated — use ccp oidc update to fix them up.
Unlinking
Edit .cluster/config.json and set oidc_client_id to an empty string (or delete the field). Subsequent deploys won't auto-register. Already-registered URIs stay on the client; remove them with ccp oidc update --remove-redirect-uri if you're decommissioning the project.
Use the Client in Your App
The create output gives you everything your OIDC library needs:
// Example with `openid-client` (Node):
import { Issuer } from "openid-client";
const issuer = await Issuer.discover("https://accounts.cluster.app");
const client = new issuer.Client({
client_id: process.env.CLUSTER_CLIENT_ID, // the "cluster-..." value
client_secret: process.env.CLUSTER_CLIENT_SECRET, // the one-time secret
redirect_uris: ["https://myapp.com/auth/callback"],
response_types: ["code"],
});Same shape for any OIDC-compliant library — golang.org/x/oauth2, Python authlib, Rust openidconnect, browser-side oidc-client-ts. Point them all at https://accounts.cluster.app as the issuer and use the client_id / client_secret you got from ccp oidc create.
id_token claims that ship out of the box:
| Claim | Source |
|---|---|
iss | https://accounts.cluster.app |
sub | Stable user identifier (Kratos identity UUID) |
aud | Your client_id |
email | User's email |
name | User's display name |
exp, iat, nbf | Standard JWT timestamps |
Verify the signature against https://accounts.cluster.app/.well-known/jwks.json — every modern OIDC library does this for you when given the issuer URL.
List Clients
ccp oidc ls
# • myapp third-party
# id: oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
# client_id: b6e3f2a1-90b5-4532-9139-13baa05ad0b3
# 2 redirect URIs: https://myapp.com/auth/callback, http://localhost:3000/auth/callback
#
# • internal-tool first-party
# id: oc-7c89df42-8a31-4d67-b023-1c4e5f6a78b9
# client_id: f48c1023-5d72-4a98-b6e1-9028fa37c145
# 1 redirect URI: https://tool.internal.cluster.app/cblist is an alias for ls. The org is resolved the same way as create.
Show Client Details
ccp oidc info oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
# • myapp
# id: oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
# client_id: b6e3f2a1-90b5-4532-9139-13baa05ad0b3
# organization_id: org_abc123
# first_party: false
# token_endpoint_auth_method: client_secret_basic
# redirect_uris:
# - https://myapp.com/auth/callback
# - http://localhost:3000/auth/callback
# scopes:
# - openid
# - email
# - profile
# - offline_access
# grant_types:
# - authorization_code
# - refresh_token
# created_at: 2026-05-24T17:08:21Z
# updated_at: 2026-05-24T17:08:21Zinfo never includes the client_secret. If you've lost it, see Rotate the Secret below.
Update a Client
Adjust the client's redirect URIs, name, or first-party flag without rotating the secret. Every invocation is one atomic PATCH; the client_secret is never touched.
# Add or remove redirect URIs (both flags repeatable, applied in one PATCH)
ccp oidc update oc-5d9f2c3b-... --add-redirect-uri https://staging.myapp.com/auth/callback
ccp oidc update oc-5d9f2c3b-... --remove-redirect-uri http://localhost:9999/auth/callback
# Combined add + remove for a URL rotation
ccp oidc update oc-5d9f2c3b-... \
--add-redirect-uri https://new.myapp.com/auth/callback \
--remove-redirect-uri https://old.myapp.com/auth/callback
# Rename the client (still unique per org)
ccp oidc update oc-5d9f2c3b-... --name myapp-v2
# Toggle the first-party flag
ccp oidc update oc-5d9f2c3b-... --first-party
ccp oidc update oc-5d9f2c3b-... --no-first-partyMost users won't run update directly — linked projects keep redirect URIs in sync automatically (see Auto-register on Deploy). Reach for update when:
- You need to register a preview URL (which auto-register intentionally skips).
- You're cleaning up stale URIs from old deploys or custom domains.
- The project isn't linked and you're managing URIs by hand.
- You're scripting a redirect-URI rotation across deploys.
Guardrails:
- Self-canceling input is rejected.
--add-redirect-uri X --remove-redirect-uri Xerrors before any network call, because the local merge would produce a zero-diff PATCH that prints success without changing anything. - Empty result is rejected. A removal that would leave the client with zero redirect URIs is rejected — the server enforces
cardinality >= 1. Add a replacement first, then remove. - At least one flag required. Calling
updatewithout any of--add-redirect-uri,--remove-redirect-uri,--name,--first-party, or--no-first-partyerrors before any network call.
Rotate the Secret
ccp oidc rotate-secret oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
# Rotate secret for oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b? The current secret is invalidated immediately. (y/N): y
# ✓ Rotated secret for myapp
#
# Issuer: https://accounts.cluster.app
# Client ID: b6e3f2a1-90b5-4532-9139-13baa05ad0b3
# Client Secret: newSecretHere...
#
# ⚠ Save the secret now — it cannot be recovered later.Rotation invalidates the old secret immediately — there is no grace window. Any deployed instance of your app still using the old secret starts getting 401 invalid_client on its next /oauth2/token call. Plan a coordinated deploy: rotate, update the env var in your hosting platform, re-deploy.
-y skips the confirmation prompt.
Destroy a Client
ccp oidc destroy oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
# Destroy OIDC client oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b? Every relying-party request using its credentials will fail. (y/N): y
# ✓ OIDC client destroyedrm is an alias for destroy. Every active relying-party session that still has an unexpired id_token keeps working until the token expires, but no new tokens can be minted: /oauth2/auth returns 400 invalid_client immediately and /oauth2/token rejects authorization codes.
-y skips the confirmation prompt.
Capturing the Secret
create and rotate-secret print the secret to stdout; the "save it now" warning prints to stderr. So redirecting stdout captures the secret cleanly:
ccp oidc create --name myapp --redirect-uri https://myapp.com/cb > secret.txt
# warning lands on terminal (stderr)
# secret + Client ID + Issuer in secret.txtIn headless mode (CCP_HEADLESS=1) the same redirect works, and the rotate-secret / destroy confirmation prompts auto-accept — see the Headless mode reference for the broader contract.
Redirect URI Requirements
- Must be
https://*orhttp://localhost(:port)/http://127.0.0.1(:port). - Must not include a URL fragment (
#anything). - Must not include a
*substring (no wildcards). - Must not include userinfo (
https://user:pass@host/cb). - Matching is exact. Re-register if you change the host, path, port, or query string.
For local development, use http://localhost:<port>/auth/callback — the validator accepts loopback over plain HTTP. For everything else, HTTPS is required.
Subcommand Reference
| Subcommand | Aliases | Description |
|---|---|---|
create | — | Register a new client (prints one-time client_secret) |
ls | list | List clients in the resolved org |
info <client_id> | — | Show details for one client (no secret) |
link <client_id> | — | Link the current project to an OIDC client; backfills redirect URIs by default |
update <client_id> | — | Change name, first-party flag, or add/remove redirect URIs (one atomic PATCH) |
rotate-secret <client_id> | — | Generate a new secret; old secret invalidated immediately |
destroy <client_id> | rm | Tear down the client |
How It Works
createPOSTs to/api/v1/oidc/clientsagainst the org-scoped infra-api, which then registers the client with the identity provider (Ory Hydra) on the cluster's admin port. The plaintextclient_secretis returned in the response — Hydra stores only a bcrypt hash, so subsequent reads will not include the secret.updatePATCHes the same endpoint with pointer-typed fields: omitted = preserve, present = replace.--add-redirect-uri/--remove-redirect-uriare merged locally against the current list and sent as one full-replaceredirect_urisarray; the rest of the update (name, first_party) ships in the same body.rotate-secretis a composition: the API fetches the current client from Hydra, generates a new 32-byte random secret, and PUT-replaces the client with the new secret. There is no atomic rotate endpoint and no grace window.linkwritesoidc_client_idto.cluster/config.json, then (by default) GETs/api/v1/serverless/functions/{id}to enumerate the project's production deploy URL + custom-linked domains, and registers them with the OIDC client in one batched PATCH. The deploy/promote/domain-link auto-register hooks read the sameoidc_client_idfield to know which client to update.destroydeletes from both Hydra and the infra-api mirror table. The relying-party app starts getting400 invalid_clientfrom/oauth2/authon the next request.- Org membership is enforced server-side on every write — a leaked
oc-…id from another org cannot be probed.
Limits
| Limit | Value |
|---|---|
| OIDC clients per organization | 25 |
| Redirect URIs per client | Capped by request body size (1MB); practical limit ~thousands |
| Client name length | 128 characters |
The per-org cap is configurable on the server side; reach out if you have a legitimate need to register more than 25 clients in a single org.