Integrate your app
Integrate your app
Every SDK starts the same way:
createCboxId({ issuer, clientId, redirectUri })
…and nothing in the SDK READMEs said where those three values come from. This page is that missing step. It takes about two minutes.
1. Sign in to the environment console
Sign in to your workspace. If it has exactly one environment you may administer, you
land in that environment's console straight away; otherwise open Projects and choose
Open console beside the environment. The environment console is /admin on the
environment's own host, https://<environment>.cboxid.com/admin. If you have not created
an environment yet, sign up at cboxid.com (or, self-hosting, follow the self-hosting quickstart) first.
Hosted environments live under cboxid.com, one subdomain per environment. If you run
Cbox ID yourself, substitute your own host everywhere this page writes
<environment>.cboxid.com; nothing else on the page changes.
The badge beside the environment name tells you which realm you are in. Register test integrations in a sandbox environment, not production: an environment is a hard isolation boundary with its own users, keys and issuer, so a client registered in one does not exist in the other.
2. Register the application
Developers → Applications → New app. (A new environment's Get started page does the same in one step, for a framework you pick.)
The form asks one question — what kind of app is this? — and everything the specification would have you decide separately follows from the answer:
| You answer | You get |
|---|---|
| Web app | Confidential client, authorization code + refresh, redirect URIs. |
| Single-page or mobile app | Public client, authorization code with PKCE, redirect URIs. |
| CLI or device | Public client, the device grant, no redirect URI. See Sign in from a CLI. |
| Service or background job | Confidential client, client credentials, no person involved. |
| AI agent | Confidential client, CIBA — it asks a person to approve first. |
| Something else | The grants and client type, picked by hand. |
Then a name (what people see on the consent screen), the redirect URIs if the kind needs them, and the scopes.
Two things that trip people up:
- Redirect URIs are matched exactly.
https://app.example.com/callbackandhttps://app.example.com/callback/are different URIs. The one exception is a loopback address (http://127.0.0.1:PORT/…), where the port may differ from the one you registered — native apps bind an ephemeral port on each run (RFC 8252 §7.3). - PKCE is required, and only
S256is accepted. Every SDK on this platform does it for you; if you are hand-rolling,plainwill be refused.
3. Copy the credentials
On the application's page:
- Client ID — safe to ship in a browser bundle.
- Client secret — shown once, at creation. It is stored hashed, so it cannot be shown again; if you lose it, rotate it. Confidential clients only.
4. Find your issuer
Your issuer is your environment's own base URL. Confirm it — and everything else an SDK needs — from the discovery document:
curl https://<environment>.cboxid.com/.well-known/openid-configuration
The issuer value in that response is exactly what you pass to the SDK. Use it verbatim:
a conformant client compares it against the iss it receives and refuses a mismatch.
5. Point an SDK at it
| Stack | Install | Walkthrough |
|---|---|---|
| Next.js, or any Node server | npm install @cboxdk/id-js |
Next.js quickstart |
| React (widgets, with id-js on a server) | npm install @cboxdk/id-js @cboxdk/id-react |
React quickstart |
| Nuxt | npm install @cboxdk/id-nuxt |
Nuxt quickstart |
| Laravel | composer require cboxdk/laravel-id-client |
Laravel quickstart |
| Go | go get github.com/cboxdk/id-go |
Go quickstart |
| Python | pip install "cbox-id-client @ git+https://github.com/cboxdk/[email protected]" |
Python quickstart |
Python installs from a git tag because cbox-id-client is not on PyPI:
pip install cbox-id-client finds no such package. It has never been published, and its
README says no release pipeline is planned. The SDK itself works; pin the tag so an
install is repeatable.
A React app needs a server for the sign-in itself. Cbox ID's discovery document, token endpoint and JWKS send no CORS headers, so a browser on another origin cannot complete the code exchange; the React quickstart shows the small server that does it.
import { createCboxId } from '@cboxdk/id-js/nextjs';
export const cbox = createCboxId({
issuer: 'https://acme.cboxid.com',
clientId: 'cid_...',
redirectUri: 'https://app.example.com/callback',
});
6. Talking to Cbox ID from the browser
Everything above gives your server a client secret. A page cannot hold one, which is why an embedded sign-in form has historically needed your backend to sit in the middle and proxy the details it wanted to render.
A publishable key removes that middle step. It is public on purpose — it goes in your JS bundle, it is visible in devtools, and it is safe there because it only works from the origins you register.
Create one under Developers → API keys, on the Publishable keys tab
(/admin/keys/frontend). See Keys. Add every origin your app is served
from, one per line:
https://acme.com
https://www.acme.com
http://localhost:3000
Exact matches only. https://acme.com does not cover https://www.acme.com — that
is deliberate, because every looser comparison anyone has written for this has been
somebody's vulnerability. Plain http is refused except on localhost, where a browser
treats it as trustworthy anyway.
Then, in your frontend:
import { CboxIdFrontend } from '@cboxdk/id-js'
const frontend = new CboxIdFrontend({
issuer: 'https://acme.cboxid.com',
publishableKey: 'pk_live_…',
})
const config = await frontend.config()
// → endpoints, social buttons, and this environment's theme
That document is everything needed to draw a sign-in box and nothing that identifies anybody: no emails, no counts, no ids. It is the same information a person can already read by viewing source on the hosted sign-in page — what changes is who can render it.
The key grants nothing on its own. To find out who is signed in, the access token your app already holds is the authority:
const { user } = await frontend.session(accessToken) // { id, email, name } or null
Signed out answers { user: null } with a 200 rather than an error, so a component that
renders on every page does not have to treat a rejection as a state.
If it does not work, the answer is almost always the origin list. A refused request deliberately carries no CORS headers — the browser shows a CORS failure rather than a readable error, because a page has no business reading the body of a rejection it was not authorized to make. Check the exact origin in your browser's network tab against the list on the key.
7. Drawing your own sign-in form
frontend.config() tells a page what to draw. frontend.signIn() lets it do the signing
in, without sending the person to the hosted page at all:
const result = await frontend.signIn(email, password)
if (result.status === 'ok') {
window.location.href = `${config.endpoints.authorization}?${new URLSearchParams({
client_id, redirect_uri, response_type: 'code',
code_challenge, code_challenge_method: 'S256',
login_ticket: result.loginTicket,
})}`
}
You get a ticket, never a token. That is the whole design. Handing tokens to a page
that proved a password is the implicit grant, which OAuth 2.1 removes — tokens in a URL, in
history, in Referer, with no client authentication and no PKCE binding. The ticket is
single-use, lasts sixty seconds, and is spent on the ordinary authorize flow with your own
PKCE challenge. Nothing about how tokens are issued changes; only how the person arrived.
The credential check itself is the same code the hosted form runs: the same lockout counter, the same breach check, the same MFA branch, the same audit entries. There is no second implementation to fall behind.
Handle three other outcomes:
| status | what it means |
|---|---|
mfa_required / otp_required |
right password, second factor still needed — finish with submitSecondFactor(result.mfaToken, code, method), where method is 'otp' for an emailed code and 'mfa' for an authenticator. The third argument is not optional: an emailed code answered as 'mfa' is checked against the wrong challenge and refused. |
sso_required |
this organization mandates single sign-on — send them to their IdP |
invalid |
wrong password, unknown address, or locked account |
Present invalid the same way every time. The server refuses to tell those three apart
because that is the enumeration oracle every identity product eventually leaks; a UI that
tells them apart rebuilds it.
The pending token carries the half-finished sign-in, because a cross-origin page has no session cookie to carry it in. A TOTP code and a recovery code both work. A wrong code costs an attempt rather than the whole sign-in — five are allowed, which is what stops a mistyped digit sending somebody back to the password field while still bounding a brute-force of a six-digit space.
Passkeys, from your own button
const options = await frontend.passkeyOptions()
const assertion = await navigator.credentials.get({ publicKey: { ...options, challenge: decode(options.challenge) } })
const result = await frontend.signInWithPasskey(options.challenge_token, serialise(assertion))
The relying party is this issuer's, not your page's. WebAuthn binds an assertion to the
origin that asked for it — that is what makes a passkey phishing-resistant — so an embedded
button on acme.com authenticates against your Cbox ID host's rpId. If your page is on a
different registrable domain, passkeys need the hosted page or a subdomain of your issuer.
That is WebAuthn working as designed, and it is the first thing that surprises people.
The result is a login ticket, exactly like a password sign-in, so everything after it is the same flow.
Guessing is rate limited per email address, not just per key — an attacker spreading attempts across pages holding the same key would otherwise sit under a per-key limit.
8. Organizations
A person can belong to several organizations, and every token is for one of them. To ask for a particular one, let people pick or switch, let them create one, or let strangers sign up from your app, see Organizations in your app.
When it does not work
The token endpoint returns an RFC-shaped error, and for most failures an
error_description that says what actually went wrong — read the description, not just
the code.
Two codes are deliberately bare. invalid_client and unauthorized_client carry no
description, because a description would tell an unauthenticated caller which half of a
credential was wrong and turn the endpoint into a client-enumeration oracle. For those
two, work from the table below rather than waiting for the server to explain itself.
| What you see | Usually means |
|---|---|
invalid_client |
Wrong client ID or secret — or a confidential client sent no credential at all. |
unauthorized_client |
The client is not registered for the grant it asked for (step 2). |
invalid_grant |
The code expired, was already used, or the redirect_uri on the exchange does not match the one on the authorize request. |
invalid_request with a PKCE mention |
No code_challenge, or plain instead of S256. |
A discovery iss mismatch |
You passed the apex URL instead of the environment's own issuer (step 4). |
| 404 from discovery, JWKS or the token endpoint on the apex host | Not a misconfigured client — on a multi-tenant deployment the apex host refuses to act as an identity provider, and 404s the whole protocol surface on purpose. Only an environment's own host serves it. Use the issuer from step 4, and see the IdP-surface gate. |