Skip to content

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/callback and https://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 S256 is accepted. Every SDK on this platform does it for you; if you are hand-rolling, plain will 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.