Skip to content

Authorization Code + PKCE

The flow every third-party integration uses to get access to one customer's organisation, with that organisation's consent.

PKCE and state are required, not optional

Every authorize request must include code_challenge / code_challenge_method=S256 and a unique, unpredictable state. S256 is the only supported transform — code_challenge_method=plain is refused outright with invalid_request, it is never silently accepted or downgraded. Never put an access or refresh token in a URL (query string or fragment) — only ever in a request body or an Authorization header.

Generate state yourself, from a CSPRNG, with at least 128 bits of entropy (e.g. 32 random bytes, base64url-encoded), and bind it to the user's current session server-side before redirecting — never accept a state your own backend didn't mint for that session. This is what makes the callback's state check (step 3) an actual defence against cross-site request forgery, not a formality.

1. Send the user to the authorize URL

GET /oauth/authorize/
  ?client_id=<your client_id>
  &redirect_uri=<one of your registered redirect URIs>
  &response_type=code
  &scope=<space-separated scopes>
  &state=<random, unguessable, one-time value>
  &code_challenge=<base64url(sha256(code_verifier))>
  &code_challenge_method=S256

Generate code_verifier fresh per authorization attempt (a random 43–128 character string), keep it client-side only, and derive code_challenge from it.

The user sees a consent screen for exactly one organisation per consent — your app's name, the scopes requested in plain language, your Verified badge (or a "Not verified" notice if you haven't been through verification), and any connection quota that applies. See Scopes and role tiers for what each scope means and who may grant it.

3. Callback

On approval, LuciBook redirects to your redirect_uri with:

?code=<authorization code>&state=<the state you sent>&connection_id=<uuid>

connection_id identifies the connection this consent just created — save it. There is no organisation claim in the access token: call GET /api/v1/connections (see Connections) to find out which organisation(s) a token can act on.

Always verify the returned state matches what you sent before doing anything else with code.

connection_id travels in a URL — don't treat it as a secret, and don't log it next to identifiers

connection_id arrives in the query string of a browser redirect, so it can end up in your web server's access log, the browser's history, and (if your callback page ever links out) the Referer header of a third-party request. It is not itself a bearer credential, but avoid writing it into application logs alongside user identifiers (email, user id) — that pairing is exactly the kind of correlated record you don't want sitting in a log line.

Confused deputy: a token never proves which org it can act on

A bearer token carries no organisation claim at all. Before acting on an org_id a caller supplied — in a URL, a webhook payload, anywhere — your app must:

  1. Call GET /api/v1/connections and confirm you hold an active connection for that exact org_id.
  2. Scope every downstream API call by that same org_id (LuciBook's own endpoints re-derive authority per request from the live connection — see Connections — effective scope — but your own app must not skip this check either, e.g. before writing to a local database keyed by org).

Never infer "which org" from anything other than a connection you've just confirmed is active for that user.

4. Exchange the code for a token

At token exchange, redirect_uri matching is an exact-string comparison against the redirect_uri recorded on the authorization grant — the value you send here must equal, byte-for-byte, the one you sent at authorize.

At authorize, the registered redirect URI does not have to be a byte-for-byte match of the request: LuciBook's OAuth library (django-oauth- toolkit) matches scheme, host and path, then requires every query parameter on the registered URI to also be present on the request URI — the request is allowed to carry additional query parameters the registration didn't list. There is one more carve-out, for RFC 8252 native-app loopback redirects: if you registered a port-less http://127.0.0.1/... or http://[::1]/... URI, a request may use any port on that same host and path. http://localhost/... does not get this carve-out — only the literal 127.0.0.1/::1 hosts do.

Regardless of what's technically accepted, always send exactly the URI you registered. Relying on the query-superset or loopback-port leniency is a foot-gun the moment your registered URI or ports change, and the token exchange above has none of this leniency anyway.

For a confidential client (one with a client secret), authenticate with HTTP Basic — put client_id/client_secret in the Authorization header, not the body:

POST /oauth/token/
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
code=<code from the callback>
redirect_uri=<the exact same redirect_uri sent to /oauth/authorize/>
code_verifier=<the verifier from step 1>

A public client (no secret — mobile/desktop/SPA) omits the Authorization header entirely and sends client_id in the body instead; PKCE (code_verifier) is what authenticates the exchange in that case.

# Confidential client — HTTP Basic (preferred)
curl -s https://api.lucibook.co.uk/oauth/token/ \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri="$REDIRECT_URI" \
  -d code_verifier="$CODE_VERIFIER"
# Public client — no secret, PKCE only
curl -s https://api.lucibook.co.uk/oauth/token/ \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri="$REDIRECT_URI" \
  -d client_id="$CLIENT_ID" \
  -d code_verifier="$CODE_VERIFIER"
import requests

# Confidential client — HTTP Basic (preferred)
resp = requests.post(
    "https://api.lucibook.co.uk/oauth/token/",
    auth=(client_id, client_secret),
    data={
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": redirect_uri,
        "code_verifier": code_verifier,
    },
    timeout=10,
)
tokens = resp.json()
// Confidential client — HTTP Basic (preferred)
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
const resp = await fetch("https://api.lucibook.co.uk/oauth/token/", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
    Authorization: `Basic ${basic}`,
  },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code,
    redirect_uri: redirectUri,
    code_verifier: codeVerifier,
  }),
});
const tokens = await resp.json();

Body-post credentials — fallback only

A confidential client that truly cannot send an Authorization header may instead post client_id/client_secret as form fields (RFC 6749 §2.3.1 calls this NOT RECOMMENDED — it is more likely to leak into logs and history). LuciBook's validator supports it as a fallback, tried after HTTP Basic; prefer Basic auth whenever you can.

Sender-constrained tokens

Access and refresh tokens are plain OAuth 2.0 bearer tokens — whoever holds the token string can use it. DPoP and mTLS-bound tokens are not currently supported; there is no additional proof-of-possession step. Treat tokens exactly like a password: never put one in a URL, and store them with the same care as any other credential.

Rotating a confidential client's secret with zero downtime

You don't have to choose between rotating your secret and risking an outage. LuciBook supports a second, time-boxed secret: while it hasn't expired, either the primary or the secondary secret authenticates successfully (Basic or body-post — both fall through to the secondary the same way). Default grace window: developer.secret_rotation_grace_days = 7 days (staff- editable; see the policy defaults table).

To rotate without downtime:

  1. In My Apps → Secrets, press Rotate. This generates a brand-new primary secret and automatically demotes your current (soon to be old) primary to secondary, where it keeps authenticating for the grace window (default 7 days).
  2. Deploy the new secret everywhere that calls the token endpoint. During the grace window both the new primary and the demoted secondary work, so in-flight deploys never see an authentication failure.
  3. Once every instance is confirmed on the new secret, either let the grace window lapse on its own, or press Revoke now to drop the secondary immediately.

Do not rotate a second time before every instance is on the new secret

A second rotation replaces whatever secret is currently secondary — it does not add a second grace slot. Rotating again before every instance has picked up the previous new secret has two effects, both bad:

  • Any instance still running on the old secondary (the one from the first rotation) loses it instantly — it is overwritten, not given more grace.
  • The secret you just deployed becomes the new secondary on a fresh countdown, so it will itself stop working once that grace window lapses, even though you only just rolled it out.

Finish rolling out one rotation (step 2 above) to every instance before starting the next.

Rotating never invalidates a token that has already been issued — only future authentications at the token endpoint are affected.

5. Refresh rotation and inactivity expiry

Every refresh issues a new refresh token — the old one stops working. Store the latest one. A refresh token that goes unused for developer.refresh_token_inactivity_days days (default 60, staff-editable — see Rate limits for the platform policy defaults table, which lists the current value) expires.

Refresh-token rotation semantics

  • Rotation is atomic and unforgiving — there is no grace window. The moment a refresh succeeds, the refresh token you sent is revoked and cannot be reused, not even a second later. If two workers hold the same refresh token and both submit it — a duplicate job, a retried request, a crashed worker that never received the previous response — exactly one wins the rotation and gets the new pair; the other gets back invalid_grant for a token that is now permanently dead. Partners running multiple workers will lose access mid-batch if they refresh in parallel. Serialise refresh calls per connection across your worker pool (a lock keyed on the connection, not the process), and on invalid_grant do not retry the same refresh token — fetch whatever the winning worker stored as the latest token, or, if none is reachable, re-drive the user through step 1 for a fresh consent.
  • Inactivity expiry: a refresh token that goes unused for developer.refresh_token_inactivity_days days (default 60) is refused on its next use — this is checked at refresh time, not on a background sweep, so the token simply stops working rather than being deleted out from under you. There is no separate cap that fires purely from elapsed time while you keep refreshing regularly — stay active and a refresh token stays valid.

See Revoking tokens for the RFC 7009 endpoint that lets you (or a compromised-credential response) end a token's life early instead of waiting for inactivity expiry.

Next step

Once you can act on one organisation, see Connections for accumulating more, or Scopes and role tiers for what you can ask for.