CAIRLDocs
Integration

Add CAIRL to a base44 app

Let users of a base44 app sign in and verify their identity or age with CAIRL, using two copy-paste backend functions, one entity, and four pages.

Add CAIRL sign-in and verification to a base44 app

This guide is for a site owner whose app is built on base44 and who wants users to sign in and prove they are verified or of age through CAIRL. It assumes no developer on your side, and it is honest about what that means: there is no CAIRL SDK, plugin, or drop-in script to install. base44 does not have a one-click CAIRL connector either. What you need instead is:

  • two small base44 backend functions (complete code below),
  • one base44 entity to hold the in-flight sign-in state,
  • five base44 secrets, and
  • four base44 pages (a callback page and three result pages).

Everything on this page was walked end to end on a real base44 site in September 2026. The base44 SDK calls shown are the ones that worked then; base44's own documentation is the source of truth for its SDK, and everything about CAIRL on this page is exact. Terms like VAE, HVF, and facet are in the glossary.

Fastest route. base44's AI builder can create all of this from one prompt. The prompt at the end of this page is the same content as Parts 2 to 4, written for it. Add the secrets first (Part 2), paste the prompt, then test (Part 5).


Which CAIRL door to use

CAIRL has two entry URLs that take the same parameters and end the same way (a code on your callback). They differ in what happens in between:

EntryWhat it doesUse it for
/oauth/authorizeSigns the user in, checks that their existing CAIRL verification meets your key's required depth and falls inside its time-based freshness window, and goes straight to the consent screen when it does. Only a user who is not yet verified enough is sent through verification.Sign in with CAIRL and "prove you are 18+ / verified" buttons
/verify/startThe hosted verification flow. Every new /verify/start request runs the document and face verification for your site before consent; a member's earlier verification, on CAIRL or on your site, does not skip it.A "verify now" step for your site, regardless of prior verification

This guide uses /oauth/authorize, so a member CAIRL has already verified is not asked to verify again for your app. The quickstart covers /verify/start.


What the finished flow looks like

  1. A user who is signed in to your base44 app clicks Verify with CAIRL (or Verify my age).
  2. Your cairlStart function creates a one-time PKCE pair, a state, and a nonce that marks this browser as the one that started the flow. It saves them and returns the CAIRL URL; the page keeps the nonce and sends the user to CAIRL.
  3. CAIRL signs the user in, verifies them if your key requires more than they have, and shows a consent screen naming your site and the claims you asked for.
  4. CAIRL sends the user back to your callback page with a code and the same state.
  5. Your callback page hands code, state, and the nonce to your cairlCallback function, which checks all three, exchanges the code for a token (using your client secret, which stays in base44 secrets), reads the verified claims, and stores the result on the user.
  6. The user lands on your success page as a verified user. On test credentials step 5 stops short of the write and lands on a test page instead, which is the point of test credentials.

Your app does not see a name, date of birth, or ID photo. It sees true / false claims and a stable pseudonymous ID.


Part 1 — CAIRL side (about 5 minutes, no code)

Follow Step 0 in Getting Started to create your account, add your app as a business facet, and open the Integration setup page at /home/f/{slug}/integrate. Then:

  1. Copy your test credentials from the Your test credentials card on the Integration setup page. CAIRL created them when you created the facet; the client secret is shown once, so save both values before clicking I've saved these. If you already dismissed the card, generate new credentials on /home/f/{slug}/keys (+ Generate Key, Mode set to Test) and enable both age:18+ and identity:verified under Required Claims. Part 3 asks for both scopes, and /oauth/authorize refuses any scope the key does not carry with invalid_scope.
  2. Register your callback URL on /home/f/{slug}/connect (Sandbox tab for now). CAIRL suggests https://<your-app-domain>/auth/cairl/callback based on the website you entered; click Use this to register it. That is the page you will create in Part 4, and the value of CAIRL_REDIRECT_URI in Part 2.

Which CAIRL host. Credentials belong to the CAIRL deployment that issued them. Credentials from https://cairl.app work only against https://cairl.app; if a CAIRL team member gives you credentials for a staging deployment, they work only against that host. Part 2's CAIRL_BASE_URL is where you say which.

Test keys need no funds. They run the real flow — you sign in with your own CAIRL account and see the consent screen — and return fixture claims for the scopes you asked for, so you can test the plumbing end to end without completing identity verification. Only the claim values, the sub, and billing are synthetic; see what is real and what is synthetic in test mode.

Because those claim values are fixtures, the code in Part 3 refuses to write them onto a user record. A test response says your wiring works; it says nothing about the person using your app.


Part 2 — base44 side: secrets and an entity

Secrets. In base44's backend secrets, add:

Secret nameValue
CAIRL_CLIENT_IDThe client ID from the Your test credentials card (or from /home/f/{slug}/keys)
CAIRL_CLIENT_SECRETThe client secret shown with it
CAIRL_REDIRECT_URIThe exact callback URL you registered on CAIRL, e.g. https://myapp.com/auth/cairl/callback
CAIRL_BASE_URLhttps://cairl.app (or the staging host a CAIRL team member gave you with staging credentials)
APP_BASE_URLYour app's public origin, e.g. https://myapp.com

Paste each value with no leading or trailing characters. A client ID is cairl_test_ or cairl_live_ followed by exactly 64 letters and digits; one stray character makes CAIRL answer invalid_client.

Do not put the client secret anywhere in a page, component, or entity. It is only read inside the callback function.

Entity. Create an entity named CairlAuthSession with these text fields:

FieldNotes
stateRandom value; used to look the row up
code_verifierPKCE secret for this attempt
nonce_hashHash of the value the starting browser keeps
origin_user_idThe base44 user who started the flow, or empty if nobody was signed in
expires_atISO timestamp; the callback rejects a row past this instant

Restrict the entity so it is readable and writable only by backend functions, never by signed-in users.

origin_user_id is what stops a verified identity landing on the wrong account. The callback can run minutes after the flow started, and in that time the same browser may have signed out and into a different base44 account. Record who started the flow, and require the same answer at the end.

Rows in this entity are short-lived. The callback deletes the row it used on every path, and cairlStart sweeps out rows whose expires_at has passed. cairlStart is a public URL, so anyone can call it repeatedly and create rows; keep the sweep, and use whatever request throttling your hosting layer offers.

User fields. Add these custom fields to base44's built-in User entity. Mark cairl_sub unique: it is the only key a CAIRL sign-in may be matched on, and two concurrent first-time callbacks must not be able to create two accounts for one person.

FieldType
cairl_subtext, unique
cairl_identity_verifiedboolean
cairl_age_18_plusboolean
cairl_verified_attext

Part 3 — the two backend functions

base44 backend functions run on Deno. The code below uses the Web Crypto API and fetch, both built in, plus three base44 runtime pieces: secrets.get() for the values from Part 2, createClientFromRequest(req) to reach the signed-in user and your entities, and asServiceRole so the functions can read and write CairlAuthSession even though users cannot. Your pages call the functions with base44.functions.invoke(name, payload); the functions return JSON, never a redirect, because base44 invokes them from the page rather than by navigating the browser to them.

Both functions share three helpers. Put them in a shared file (shared/cairlCrypto.ts) or paste them at the top of each function:

export function base64url(bytes: Uint8Array): string {
  let s = "";
  for (const b of bytes) s += String.fromCharCode(b);
  return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export function randomB64url(byteLength: number): string {
  return base64url(crypto.getRandomValues(new Uint8Array(byteLength)));
}

export async function sha256B64url(text: string): Promise<string> {
  const data = new TextEncoder().encode(text);
  return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", data)));
}

Function 1: cairlStart

Creates the one-time values, stores them, and returns the CAIRL URL.

// base44 backend function: cairlStart
// Called by your "Verify with CAIRL" button via base44.functions.invoke.
import { createClientFromRequest } from "npm:@base44/sdk";
import { secrets } from "base44:runtime";
import { randomB64url, sha256B64url } from "../../shared/cairlCrypto.ts";

const SESSION_TTL_MS = 30 * 60 * 1000;

export default async function (req: Request): Promise<Response> {
  const clientId = secrets.get("CAIRL_CLIENT_ID");
  const redirectUri = secrets.get("CAIRL_REDIRECT_URI");
  const baseUrl = secrets.get("CAIRL_BASE_URL");
  if (!clientId || !redirectUri || !baseUrl) {
    return Response.json({ error: "not_configured" }, { status: 500 });
  }

  const base44 = createClientFromRequest(req);
  const now = new Date();

  // Housekeeping: abandoned flows never reach the callback, so clear the
  // dead rows on the way in.
  await base44.asServiceRole.entities.CairlAuthSession.deleteMany({
    expires_at: { $lt: now.toISOString() },
  });

  // PKCE: 32 random bytes -> verifier; SHA-256 of verifier -> challenge.
  const codeVerifier = randomB64url(32);
  const codeChallenge = await sha256B64url(codeVerifier);

  // CSRF: 24 random bytes (CAIRL requires at least 16 characters).
  const state = randomB64url(24);

  // Browser binding. The page keeps `nonce`; only its hash is stored here.
  // The callback demands both the saved row and the matching nonce, so a
  // callback URL someone else started cannot be pasted into your user's
  // browser to sign that person into the wrong account.
  const nonce = randomB64url(32);
  const nonceHash = await sha256B64url(nonce);

  // Account binding. Whoever is signed in now is the only account this
  // attempt may ever be linked to. Never read this from the request body.
  let originUserId = "";
  try {
    const user = await base44.auth.me();
    if (user?.id) originUserId = user.id;
  } catch {
    // nobody signed in
  }

  await base44.asServiceRole.entities.CairlAuthSession.create({
    state,
    code_verifier: codeVerifier,
    nonce_hash: nonceHash,
    origin_user_id: originUserId,
    expires_at: new Date(now.getTime() + SESSION_TTL_MS).toISOString(),
  });

  const params = new URLSearchParams({
    response_type: "code",
    client_id: clientId,
    redirect_uri: redirectUri,
    state,
    // Wire scopes. The response keys are age_18_plus / identity_verified.
    scope: "age:18+ identity:verified",
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  });
  const url = `${baseUrl.replace(/\/+$/, "")}/oauth/authorize?${params.toString()}`;

  return Response.json({ url, nonce });
}

Function 2: cairlCallback

Receives code, state, and the nonce from your callback page, exchanges the code, reads the claims, and records the result. It returns { next }, the path your page should navigate to.

// base44 backend function: cairlCallback
// Called by your /auth/cairl/callback page via base44.functions.invoke
// with { code, state, error, nonce }.
import { createClientFromRequest } from "npm:@base44/sdk";
import { secrets } from "base44:runtime";
import { sha256B64url } from "../../shared/cairlCrypto.ts";

function sameValue(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

const next = (path: string, reason?: string) =>
  Response.json({
    next: reason ? `${path}?reason=${encodeURIComponent(reason)}` : path,
  });
const fail = (reason: string) => next("/cairl/failed", reason);

export default async function (req: Request): Promise<Response> {
  const baseUrl = secrets.get("CAIRL_BASE_URL");
  const clientId = secrets.get("CAIRL_CLIENT_ID");
  const clientSecret = secrets.get("CAIRL_CLIENT_SECRET");
  const redirectUri = secrets.get("CAIRL_REDIRECT_URI");
  if (!baseUrl || !clientId || !clientSecret || !redirectUri) {
    return fail("not_configured");
  }
  const tokenUrl = `${baseUrl.replace(/\/+$/, "")}/api/oauth/token`;
  const userinfoUrl = `${baseUrl.replace(/\/+$/, "")}/api/oauth/userinfo`;

  const base44 = createClientFromRequest(req);
  const sessions = base44.asServiceRole.entities.CairlAuthSession;
  const body = (await req.json().catch(() => ({}))) as Record<string, string>;
  const { code, state, error, nonce } = body;

  // The user cancelled, or verification did not complete. Retire the row this
  // browser started (same state AND same nonce), never a row by state alone.
  if (error) {
    if (state && nonce) {
      const [stale] = await sessions.filter({ state });
      if (stale && sameValue(await sha256B64url(nonce), stale.nonce_hash)) {
        await sessions.delete(stale.id);
      }
    }
    return fail(error);
  }

  if (!code || !state || !nonce) return fail("missing_params");

  // 1. The row must exist, be unexpired, and belong to the browser that
  //    started the flow. Validate BEFORE consuming it: a stranger who knows
  //    a victim's `state` (it rides on the callback URL) must not be able to
  //    destroy the victim's attempt with a junk nonce. Only a validated row
  //    is deleted, and it is deleted before the code is spent: one attempt,
  //    one try.
  const [session] = await sessions.filter({ state });
  if (!session) return fail("invalid_state");
  if (!sameValue(await sha256B64url(nonce), session.nonce_hash)) {
    return fail("wrong_browser");
  }
  if (Date.now() > new Date(session.expires_at).getTime()) {
    await sessions.delete(session.id);
    return fail("expired");
  }
  await sessions.delete(session.id);

  // The account must be the same one that started the flow.
  let signedInNow = "";
  try {
    const user = await base44.auth.me();
    if (user?.id) signedInNow = user.id;
  } catch {
    // nobody signed in
  }
  if (session.origin_user_id !== signedInNow) return fail("account_changed");
  // The default recipe verifies a user who is signed in to your app. Turning
  // a signed-out visitor into a signed-in one needs base44's own session API;
  // see "Register and sign in with CAIRL" before removing this check.
  if (!session.origin_user_id) return fail("sign_in_required");

  // 2. Exchange the code for an access token (server-side only).
  const tokenRes = await fetch(tokenUrl, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      client_id: clientId,
      client_secret: clientSecret,
      redirect_uri: redirectUri,
      code_verifier: session.code_verifier,
    }),
  });
  if (!tokenRes.ok) {
    // 402 with live keys means your CAIRL balance needs funds.
    return fail(
      tokenRes.status === 402 ? "payment_required" : "exchange_failed",
    );
  }
  const token = await tokenRes.json();
  if (typeof token.access_token !== "string") return fail("exchange_failed");

  // 3. Read the verified claims.
  const infoRes = await fetch(userinfoUrl, {
    headers: { Authorization: `Bearer ${token.access_token}` },
  });
  if (!infoRes.ok) return fail("userinfo_failed");
  const info = await infoRes.json();

  // 4. Test credentials return fixture claims that say nothing about the
  //    person in front of you, so they stop here and never reach the User
  //    record. Anything other than "live" is treated as a test run.
  if (info.mode !== "live") return next("/cairl/test-complete");

  if (typeof info.sub !== "string" || info.sub.length === 0) {
    return fail("userinfo_failed");
  }
  const claims = info.claims ?? {};
  const fields = {
    cairl_sub: info.sub,
    cairl_identity_verified: claims.identity_verified === true,
    cairl_age_18_plus: claims.age_18_plus === true,
    cairl_verified_at: info.evaluated_at,
  };

  // 5. Record the result on the user. `sub` is stable for your site, and the
  //    account it may be written to is the one that started the flow.
  const users = base44.asServiceRole.entities.User;
  const [existing] = await users.filter({ cairl_sub: info.sub });
  if (existing && existing.id !== session.origin_user_id) {
    // This CAIRL identity is already linked to a different account. Refuse
    // rather than move it: `cairl_sub` is unique and two accounts for one
    // person is exactly what the uniqueness field prevents.
    return fail("already_linked");
  }
  const [origin] = await users.filter({ id: session.origin_user_id });
  if (!origin) return fail("account_changed");
  if (origin.cairl_sub && origin.cairl_sub !== info.sub) {
    // The account is already bound to a different CAIRL identity. Never
    // overwrite that binding from a sign-in: it would move verified claims
    // onto the wrong person and free the old subject for reuse. Re-linking
    // is a deliberate, separate step if you ever offer one.
    return fail("subject_mismatch");
  }
  // Link (or refresh) the CAIRL identity on the account that started the
  // flow. Never match on email — CAIRL does not share it.
  //
  // Compare-and-set. The guard above only read the row, so two first-link
  // callbacks carrying two different CAIRL subjects can both see an empty
  // `cairl_sub`, both pass, and the second write would silently replace the
  // first binding. The write therefore happens only while `cairl_sub` is
  // empty or already this subject, and the row is read back afterwards: an
  // attempt that no longer owns the binding is told `subject_mismatch`
  // rather than reporting a link it does not hold.
  await users.update(session.origin_user_id, fields);
  const [bound] = await users.filter({ id: session.origin_user_id });
  if (!bound || bound.cairl_sub !== info.sub) return fail("subject_mismatch");

  // 6. Done — but "done" is not "eligible". The booleans just stored are
  //    the authority for what this person may do; the success page only
  //    says the sign-in completed. Route on the claims your app requires so
  //    an underage or unresolved user never lands on a page that reads as
  //    admission.
  const eligible =
    fields.cairl_identity_verified === true &&
    fields.cairl_age_18_plus === true;
  return next(eligible ? "/cairl/success" : "/cairl/not-eligible");
}

Notes on the code:

  • state and the nonce are checked before anything else, and both have to line up. state alone proves only that some row exists, which someone else could have created; the nonce is what ties that row to the browser standing in front of you.
  • The session row is deleted the moment it is read, so one attempt cannot be replayed through your app. That delete is not a claim: two invocations that arrive together can both validate the row before either delete commits, and the one that loses the exchange would land on /cairl/failed even though the account was updated. The callback page in Part 4 is what stops that — it calls the function once per page load.
  • The write to the User record is a compare-and-set: the row is read back after the update, and an attempt that no longer holds the binding returns subject_mismatch. Two first-link callbacks for two different CAIRL subjects on one account both read an empty cairl_sub and both pass the guard, so the read-back is what keeps the loser from reporting a link it does not own.
  • mode decides whether the result is allowed to touch a real account. Test credentials get the test page; only "live" writes verified status.
  • claims.age_18_plus and claims.identity_verified are compared with === true on purpose: a claim can be false or null (could not be resolved), and both mean "do not treat as verified".
  • reason is passed through encodeURIComponent because it comes off the incoming URL, and untouched text from a URL does not belong in a navigation target.
  • The token exchange must happen here, in the function, because it uses your client secret. Do not move it into a page.
  • The recipe as written verifies a signed-in user: a visitor who is not signed in to your app is sent to /cairl/failed?reason=sign_in_required. Label the button Verify with CAIRL until you add the session step in "Register and sign in with CAIRL" below; a button that says "Sign in" but leaves the visitor signed out is a broken promise.

Part 4 — pages

The button. On a page your signed-in users see, add Verify with CAIRL. Do not label it "Sign in": the recipe below verifies a user who is already signed in to your app, and a signed-out visitor is sent to the failure page with sign_in_required. The label changes only once the optional session-establishment extension in "Register and sign in with CAIRL" is in place. On click:

try {
  const { data } = await base44.functions.invoke("cairlStart", {});
  if (data?.url && data?.nonce) {
    sessionStorage.setItem("cairl_flow", data.nonce);
    window.location.assign(data.url);
    return;
  }
  window.location.assign(
    `/cairl/failed?reason=${encodeURIComponent(data?.error ?? "start_failed")}`,
  );
} catch {
  window.location.assign("/cairl/failed?reason=start_failed");
}

The callback page at the route you registered (/auth/cairl/callback). On load, exactly once:

// Once per page load. A React effect runs twice in development, and a stray
// re-render can land here again: two invocations can both read and validate
// the same session row before either delete commits, and the one that loses
// the exchange would send a user whose account was already updated to
// /cairl/failed. The ref is declared in the component and checked inside the
// effect, so exactly one call is made per mount.
const started = useRef(false);

useEffect(() => {
  if (started.current) return;
  started.current = true;

  void (async () => {
    const query = new URL(window.location.href).searchParams;
    const nonce = sessionStorage.getItem("cairl_flow") ?? "";
    let next = "/cairl/failed?reason=callback_unavailable";
    try {
      const { data } = await base44.functions.invoke("cairlCallback", {
        code: query.get("code"),
        state: query.get("state"),
        error: query.get("error"),
        nonce,
      });
      next = data?.next ?? "/cairl/failed?reason=exchange_failed";
    } catch {
      // The function never answered (network, cold start, uncaught error).
      // The CAIRL code is single-use, so the only honest outcome is the
      // failure page with a Try again button — never a blank
      // "Finishing sign-in…".
    }
    sessionStorage.removeItem("cairl_flow");
    window.location.assign(next);
  })();
}, []);

Show a short "Finishing sign-in…" message while it runs. sessionStorage is per tab, and CAIRL returns the user to the same tab, so the nonce is there when the callback page loads; a callback URL opened in a different tab or browser has no nonce and ends in wrong_browser, which is the point.

Three result pages:

  • /cairl/success — "You're verified with CAIRL and meet this site's requirements." Reached only when every claim your app requires came back true; the stored booleans remain the authority for any later check.
  • /cairl/not-eligible — "CAIRL couldn't confirm you meet this site's requirements." The sign-in completed and the claims were stored as false or unresolved; the page must not read as admission.
  • /cairl/test-complete — "Test run complete. The connection to CAIRL works. These were fixture claims, so nothing was recorded on this account and no one was verified." Keep this page after you go live: it is the signal that a deployment is still on test credentials.
  • /cairl/failed — show the reason query value and a Try again button that runs the same start flow.

Which callback URL to register. Register the page URL (https://<your-app-domain>/auth/cairl/callback), which is the one CAIRL suggests on Connect. Because the page calls cairlCallback through base44's SDK, the function does not need a public URL of its own and no cookie has to survive the round trip. The URL you register must match CAIRL_REDIRECT_URI character for character, and it may not contain a query string, # fragment, or wildcard.


Part 5 — test it

  1. With the test client ID and secret in base44 secrets and the callback URL registered on the Sandbox tab, sign in to your app and click Verify with CAIRL.
  2. You land on CAIRL. Sign in with your own CAIRL account (you do not need to have completed identity verification), then read the consent screen: it names your app and lists age:18+ and identity:verified. Click Allow.
  3. CAIRL sends you back to your callback with a code. Your function exchanges it, reads fixture claims — age_18_plus: true and identity_verified: true — sees "mode": "test" in the response, and sends you to /cairl/test-complete. That page is the success signal for a test run: it means every piece is wired up. The User record is deliberately left alone, because a fixture claim is not evidence about a person.
  4. Click the button again. Your consent is remembered, so CAIRL sends you back without prompting. The test sub is a random pws_v1_test_… value, so do not expect it to match between runs; the same-sub matching that recognises a returning user is exercised with live credentials.

If you get bounced to /cairl/failed:

reasonWhat it means
not_configuredA secret from Part 2 is missing.
sign_in_requiredNobody was signed in to your app when the flow started. The default recipe verifies signed-in users; see "Register and sign in with CAIRL".
already_linkedThis CAIRL identity is already linked to a different account on your app.
subject_mismatchThe signed-in account is already bound to a different CAIRL identity; a sign-in never rebinds it.
start_failedcairlStart returned an error or did not answer; check its logs and the five secrets.
callback_unavailableYour cairlCallback function did not answer. Check its logs; the user can start over with Try again.
missing_paramsNo code, no state, or no nonce in sessionStorage. The callback page opened in a different tab or browser than the button.
invalid_stateThe CairlAuthSession row was not found. Check the entity name and that cairlStart can write to it.
wrong_browserThe row exists but the nonce does not match it. The flow was finished somewhere other than where it began.
account_changedThe signed-in account is not the one that started the flow. Sign back in as that account and start over.
exchange_failedThe token call was rejected. Most often CAIRL_REDIRECT_URI differs from the registered URL, the secret is wrong, or (live keys) the person is not certified on this CAIRL host.
access_deniedThe user declined on the consent screen.
expiredMore than 30 minutes passed. Start over.
payment_requiredLive key with insufficient funds. Add funds at /home/f/{slug}/billing.

If CAIRL itself shows an error page instead of sending the user back, the cause is on the request. This guide's cairlStart uses /oauth/authorize, which reports: unauthorized_client when the client ID does not match any key on that CAIRL host (check for a stray character, and that CAIRL_BASE_URL is the host that issued the credentials); invalid_request when the redirect_uri is not registered exactly as sent or a parameter is missing; invalid_scope when a value in scope is not one of the wire scopes. The hosted /verify/start entry names the same faults invalid_client and invalid_redirect_uri.


Part 6 — go live

  1. Add funds at /home/f/{slug}/billing (Add Funds, $50 minimum). The live environment activates on the first load, and the callback URL you registered in sandbox is carried into live automatically; confirm it on the Live tab of /home/f/{slug}/connect.
  2. On /home/f/{slug}/keys, click Create live credentials. The button appears once funds are loaded, and nothing is created until you click it. The live client ID and secret are shown once; copy both. If you would rather choose the key's Required Claims or freshness policy yourself, use + Generate Key with Mode set to Live, and keep both age:18+ and identity:verified enabled: the recipe asks for both scopes, and /oauth/authorize refuses any scope the key does not carry with invalid_scope. To run on one claim instead, change two places together — the scope string in cairlStart and the eligible check in cairlCallback — so what you ask for, what the key allows, and what you gate on stay the same set.
  3. Replace CAIRL_CLIENT_ID and CAIRL_CLIENT_SECRET in base44 secrets with the live values, and make sure CAIRL_BASE_URL is https://cairl.app.
  4. Run the flow once more and confirm you land on /cairl/success, not /cairl/test-complete, and that the User record now carries cairl_sub and the two booleans. Landing on /cairl/test-complete means the deployment is still reading test credentials — check step 3 before announcing the feature.

From then on, real users go through real verification when your key requires more than they already have, and the claims come from that verification rather than from fixtures. You are charged per Verified Access Event at token exchange, and only when the exchange succeeds. Abandoned or failed verifications are not billed.


Register and sign in with CAIRL

CAIRL returns a sub that is stable for your site: the same person gets the same sub every time on your app, and a different sub on any other site. That is what makes it usable as an account key.

  • Verification for existing users. If a user is already signed in to your base44 app when they click Verify, cairlCallback writes the claims onto their existing account. base44's built-in sign-in stays as it is.
  • Sign in with CAIRL (extension, not in the copy-paste recipe). To let a signed-out visitor create an account or sign in through CAIRL, three things change in cairlCallback: drop the sign_in_required check; when no User matches cairl_sub, create one (with cairl_sub unique, a concurrent duplicate create fails — catch that and re-read the existing User by cairl_sub); and, on every live path, establish a base44 session for that User the way base44's documentation describes for backend-initiated sign-in, before returning /cairl/success. That session step is base44's API and this page does not write it for you; until it is in place, do not label the button "Sign in".
  • What CAIRL is not. CAIRL is not a password replacement for base44's built-in sign-in on its own. If some users sign in with a base44 password and others with CAIRL, you have two front doors to the same account table. That is fine as long as cairl_sub is the only way a CAIRL sign-in is matched to an account — do not match on email, because CAIRL does not share it.

All three of these run only on a live response. With test credentials the callback stops at /cairl/test-complete, so no account is created, linked, or signed in.

Keep cairl_sub private. It is personal data under most privacy laws even though it contains no name.


What you have at the end

  • Two backend functions, one entity, five secrets, and four pages.
  • Users who can sign in and prove they are verified or 18+ without your app ever holding an ID document.
  • A claims snapshot per sign-in. To re-check a user later (for example every 90 days), run the flow again; CAIRL skips re-verification for users who are still current.

Appendix — one prompt for base44's AI builder

Add the five secrets from Part 2 first (never put their values in the prompt), then paste everything below as one message.

Add "Verify with CAIRL" to this app. CAIRL is an OAuth 2.0 + PKCE identity
provider. Build exactly the following; do not invent extra steps, do not store
the client secret anywhere except backend secrets, and do not call CAIRL from
the frontend.

1. Data. Create an entity CairlAuthSession with text fields state,
   code_verifier, nonce_hash, origin_user_id, expires_at; only backend
   functions may read or write it. Add custom fields to the built-in User
   entity: cairl_sub (text, unique), cairl_identity_verified (boolean),
   cairl_age_18_plus (boolean), cairl_verified_at (text).

2. Backend function cairlStart (Deno), invoked from the frontend with
   base44.functions.invoke("cairlStart", {}). Read secrets CAIRL_CLIENT_ID,
   CAIRL_REDIRECT_URI, CAIRL_BASE_URL inside the handler with secrets.get from
   base44:runtime; if any is missing return { error: "not_configured" }.
   Delete every CairlAuthSession whose expires_at is earlier than now (use
   asServiceRole). Generate with crypto.getRandomValues: code_verifier =
   base64url of 32 random bytes; code_challenge = base64url(SHA-256(
   code_verifier)) with Web Crypto; state = base64url of 24 random bytes;
   nonce = base64url of 32 random bytes, storing only nonce_hash =
   base64url(SHA-256(nonce)). base64url = standard base64 with + -> -, / -> _,
   trailing = removed. origin_user_id = the id of the currently signed-in
   base44 user from base44.auth.me() via createClientFromRequest(req), or ""
   if nobody is signed in; never read it from the request body. Create the
   CairlAuthSession row (asServiceRole) with expires_at = now + 30 minutes as
   an ISO string. Build the URL `${CAIRL_BASE_URL}/oauth/authorize?` +
   URLSearchParams of response_type=code, client_id, redirect_uri (=
   CAIRL_REDIRECT_URI), state, scope = "age:18+ identity:verified" (exactly),
   code_challenge, code_challenge_method=S256. Return JSON { url, nonce }.

3. Backend function cairlCallback (Deno), invoked from the callback page with
   base44.functions.invoke("cairlCallback", { code, state, error, nonce }). It
   returns { next: "<path>" } and the page navigates there. Every path deletes
   the session row it used. Failure paths return { next:
   "/cairl/failed?reason=<reason>" } with the reason passed through
   encodeURIComponent. Steps: (a) if error is present, find the row by state;
   if it exists and base64url(SHA-256(nonce)) equals its nonce_hash, delete
   it; return failed with reason = error. (b) If code, state or nonce is
   missing -> missing_params. (c) Find the row by state (asServiceRole); none
   -> invalid_state. If base64url(SHA-256(nonce)) != nonce_hash
   (constant-time compare) -> wrong_browser WITHOUT deleting the row. If now
   is past expires_at -> delete the row, expired. Otherwise delete the row
   now (single use) and continue. (d) signedInNow = current user id from
   base44.auth.me() or ""; if it differs from the row's origin_user_id ->
   account_changed; if origin_user_id is empty -> sign_in_required. (e) POST `${CAIRL_BASE_URL}/api/oauth/token` as
   application/x-www-form-urlencoded with grant_type=authorization_code,
   code, client_id, client_secret, redirect_uri (= CAIRL_REDIRECT_URI),
   code_verifier (from the row). Not ok -> payment_required if HTTP 402,
   else exchange_failed. Read access_token (string) from the JSON. (f) GET
   `${CAIRL_BASE_URL}/api/oauth/userinfo` with header Authorization: Bearer
   <access_token>. Not ok -> userinfo_failed. Parse JSON info. (g) If
   info.mode !== "live" -> return { next: "/cairl/test-complete" } and do NOT
   touch any User record. (h) Otherwise require info.sub to be a non-empty
   string. Compute fields: cairl_sub = info.sub, cairl_identity_verified =
   (info.claims.identity_verified === true), cairl_age_18_plus =
   (info.claims.age_18_plus === true), cairl_verified_at =
   info.evaluated_at. Find the User whose cairl_sub equals info.sub; if one
   exists and it is not the origin user -> already_linked. Load the origin
   user; if it has a non-empty cairl_sub different from info.sub ->
   subject_mismatch (never overwrite a binding). Otherwise update the origin
   user with the fields, then read the row back and return subject_mismatch
   unless its cairl_sub now equals info.sub (compare-and-set: two first-link
   callbacks for two different subjects can both read an empty cairl_sub, and
   the read-back is what stops the loser reporting a link it does not hold).
   Never match on email, never create users in this recipe. (i) eligible =
   cairl_identity_verified && cairl_age_18_plus; return { next: eligible ?
   "/cairl/success" : "/cairl/not-eligible" }.

4. Pages. Home page button "Verify with CAIRL": call cairlStart inside
   try/catch; if it returns url and nonce, save nonce in sessionStorage under
   key cairl_flow, then window.location.assign(url); if it returns an error
   or throws, go to /cairl/failed?reason=<error or start_failed>. Page at
   route /auth/cairl/callback: on load read code, state, error from the URL
   query and the nonce from sessionStorage["cairl_flow"]; call cairlCallback
   with { code, state, error, nonce } inside try/catch, exactly once per page
   load (guard the effect with a run-once ref: a React effect runs twice in
   development, and two invocations can both validate the same session row
   before either delete commits); on failure use
   /cairl/failed?reason=callback_unavailable; then remove
   sessionStorage["cairl_flow"] and window.location.assign(next); show
   "Finishing sign-in…" while it runs. Page /cairl/success: "You're verified
   with CAIRL and meet this site's requirements." Page
   /cairl/not-eligible: "CAIRL couldn't confirm you meet this site's
   requirements." Page /cairl/test-complete: "Test run complete. The connection to
   CAIRL works. These were fixture claims, so nothing was recorded on this
   account and no one was verified." Page /cairl/failed: show the reason
   query value and a "Try again" button that runs the same start flow.

5. Constraints. Only the two backend functions talk to CAIRL. The frontend
   never sees the client secret and never calls CAIRL's token or userinfo
   endpoints. Do not change scope, the redirect URI, or the PKCE method. Keep
   /cairl/test-complete after go-live.

Related: Getting Started · Claims reference · Error reference · Glossary

On this page