When a function has auth_level: "user" or an LLM service uses user_pays billing, the app authenticates Gipity users via session cookies.

The auth_level gate runs at the platform, before your handler. An auth_level: "user" (or "member") function called without a signed-in user is rejected with 401 LOGIN_REQUIRED before your code executes - ctx.auth is only ever populated inside the handler, so a signed-out caller never reaches it. Two consequences: (1) don't write a if (!ctx.auth?.userGuid) return { error: 'Sign in required' } guard for the unauthenticated case - it's dead code the platform already handles (inside the handler you can assume ctx.auth.userGuid is set). (2) A test that calls the function logged-out must assert the rejection (ctx.fn.call throws / the call 401s), not a returned error string - use ctx.fn.callAs(userGuid, ...) to exercise the signed-in path. Reserve in-handler checks for finer authorization (e.g. "is this row mine?"), which the gate can't express.

Personal-data apps need auth even when nothing is paid. If the request implies user-private data - "my receipts", a personal vault, private notes, anything storing a user's own uploads or records - default to per-user scoping: gate writes behind sign-in and key every row to ctx.auth.userGuid (the stable external id - not the internal numeric userId; see "Using auth" in app-development), then filter listings to that user. A "my X" app that's readable and deletable by anyone with the URL is a privacy hole. If you intentionally ship a public or shared version instead, say so explicitly in your summary so the user can decide (see "Personal data defaults to per-user scoping" in web-app-basics).

How It Works

Users logged into Gipity already have a session cookie on .gipity.ai. Your app needs two things:

  1. An app token (identifies your app to the API)
  2. credentials: 'include' on all fetch calls (sends the user's session cookie cross-origin)

Step 1: Get an App Token

POST https://a.gipity.ai/api/token with your project GUID.

const res = await fetch('https://a.gipity.ai/api/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ app: '<PROJECT_GUID>' })
});
const json = await res.json();
const appToken = json.data.token;  // IMPORTANT: .data.token, NOT .token

Response shape:

{ "data": { "token": "eyJ...", "expiresIn": 3600 } }

The token is short-lived (1 hour). Cache it for the session and refresh when it expires.

Step 2: Check Auth State (Recommended)

GET /api/<PROJECT_GUID>/auth/status - returns auth state + URLs in one call.

Add X-App-Token header and credentials: 'include'.

Response shapes:

// Not logged in
{ "authenticated": false, "consented": false, "user": null, "loginUrl": "..." }

// Logged in, no consent
{ "authenticated": true, "consented": false, "user": null, "consentUrl": "..." }

// Fully authed
{ "authenticated": true, "consented": true, "user": { "guid", "displayName", "avatarUrl", "accountSlug" } }

Optional query param: ?permissions=N (default 1 = IDENTITY). Use 5 for IDENTITY + AI.

Lightweight Check

GET /api/auth/me (with credentials: 'include') returns { user: { guid, displayName, avatarUrl, accountSlug } } or { user: null }. No app context - cannot return login/consent URLs.

API keys for scripts and agents

Sessions are for people. A script, cron job, or agent authenticates with a project API key instead: a named, revocable secret the platform mints and hashes for you. Do not build an api_keys table, token minting, a masked preview, or a revoke endpoint in the app - it already exists.

# mint (returns the full key exactly once), list, revoke - Bearer = your CLI token (gipity token create)
curl -sX POST https://a.gipity.ai/projects/<PROJECT_GUID>/api-keys -H "Authorization: Bearer $GIPITY_TOKEN" \
  -H 'Content-Type: application/json' -d '{"name":"laptop-script","role":"editor"}'
curl -s  https://a.gipity.ai/projects/<PROJECT_GUID>/api-keys   -H "Authorization: Bearer $GIPITY_TOKEN"
curl -sX DELETE https://a.gipity.ai/projects/<PROJECT_GUID>/api-keys/<ak_guid> -H "Authorization: Bearer $GIPITY_TOKEN"

The script then calls any auth: user / auth: member function with the key in a header - never in the body:

curl -sX POST https://a.gipity.ai/api/<PROJECT_GUID>/fn/add-entry \
  -H "X-Api-Key: $MY_KEY" -H 'Content-Type: application/json' -d '{"title":"Dune"}'

Inside the handler, ctx.auth says who called:

ctx.auth.via who ctx.auth.apiKeyName
'user' signed-in person (session or app token) null
'api_key' script/agent holding a project API key the key's name, e.g. 'laptop-script'
'anonymous' no credentials (auth: public only) null

An api_key call gets a complete ctx.auth, identical in shape to a signed-in caller: ctx.auth.userId, userGuid, displayName, and role are all populated, resolving to the key's creator (which is the project owner). So a script writing with a key lands in the same ctx.auth.userGuid scope as the owner adding rows by hand - key your rows to ctx.auth.userGuid exactly as for a signed-in user and the two paths share one per-user log automatically. No need to probe empirically: via/apiKeyName are the only fields that differ from a session call.

So provenance ("did I type this, or did my script add it?") is one stored column: ctx.auth.via === 'api_key' ? ctx.auth.apiKeyName : 'me'. The name is a snapshot - it stays readable on old rows after the key is revoked. The key itself never reaches your code (the platform consumes and strips the header), so it can't leak into logs.

Error Codes (Function Calls)

The function API also returns auth-related error codes with redirect URLs:

Append &return=<app_url> to redirect URLs so users return to the app after auth. The return URL must be on a Gipity-hosted domain (app.gipity.ai, dev.gipity.ai, gipity.ai).

Customizing the login page (auth_branding)

The login and consent pages are server-rendered by Gipity - your app only sends the user there - so you can't style them with your own CSS. By default they show your project name, Gipity orange, and the Gipity wordmark. To match your app, add an auth_branding: block to gipity.yaml and redeploy; gipity deploy syncs it to your project:

auth_branding:
  app_name: Lilyboxd
  primary_color: "#fea60b"      # quote it - a bare # is a YAML comment
  logo_url: https://media.gipity.ai/.../logo.png   # https; replaces the wordmark
  tagline: Your film diary

Scope: auth_branding styles only the Sign in with Gipity pages - it does not theme your app's own UI (that's your own CSS). All fields optional; omitted ones use Gipity defaults. Full field rules and validation are in deploy ("auth_branding"). It's stored on the project (not passed in the login URL) on purpose: the login page is a trust surface where the user decides whether to grant access, so its identity must come from the registered project, not from request params an attacker could spoof.

Client Code - use the SDK (do this)

Load the Gipity client SDK and let it handle sign-in. Do not hand-roll the popup, polling, or COOP workarounds - Gipity.auth.signIn() opens the popup synchronously on click and drives login + consent for you. Hand-rolling it is the #1 source of broken auth.

<script crossorigin="anonymous" src="https://media.gipity.ai/client/v1/gipity.js"></script>
<gipity-signin app-guid="<PROJECT_GUID>" permissions="1"></gipity-signin>

Or wire your own button:

Gipity.auth.configure({ appGuid: '<PROJECT_GUID>' });   // permissions defaults to 1 (identity only)
signinBtn.addEventListener('click', async () => {
  const user = await Gipity.auth.signIn(); // opens popup, handles login + consent
  if (user) showApp(user); else showSignedOut();
});
// On load, reflect any existing session (null if not signed in / not consented):
const me = await Gipity.auth.user();
if (me) showApp(me);

Consent scopes (permissions) - set this to 5 for any app that calls paid AI on the user's credits. permissions is a bitmask of the scopes the sign-in/consent popup asks the user to grant; it defaults to 1 (identity only). If your app calls an AI service under user_pays billing (Gipity.service('llm' | 'image' | 'tts' | ...) charging the signed-in user), the user must also consent to the AI scope - pass permissions: 5 (or permissions="5" on <gipity-signin>) so signIn() requests it up front. Without it the first AI call 403s with CONSENT_REQUIRED and the flow can't self-recover: a service call can't open a consent popup (browsers only allow that inside the sign-in click). The bits, OR'd together: IDENTITY = 1 (display name / avatar / account slug / user guid - always needed) and AI = 4 (call paid AI services on the user's credits), so 1 | 4 = 5. (Higher bits - 8/16/32/64 - cover this app's agent and workflows; identity-only apps stay at 1.)

Once signed in, Gipity.fn(name, body) (call a function), Gipity.service(path, body) (call an app service like 'llm' / 'image' - see app-llm), and Gipity.upload(file) all auto-attach the token and session cookie for you - never hand-roll fetch('/api/token') + X-App-Token. Each throws on non-2xx with the server message and err.status/err.code (branch 402/401). Call signIn() directly in the click handler - don't await anything before it or the browser blocks the popup.

Reacting to auth changes from elsewhere in the app - Gipity.auth.onChange. The simplest path is to re-render straight from the return values above (signIn() / signOut() resolve to the user-or-null; user() gives the initial state on load). When code that didn't trigger the change also needs to react - a header avatar, a sidebar, gated sections - subscribe once:

const off = Gipity.auth.onChange((user) => {
  if (user) showApp(user); else showSignedOut();
});
// off();  // unsubscribe when you no longer care

The callback fires with the new user (or null) only on a real transition - signed-out → user, user → signed-out, or user → different user - at the three points auth actually moves: on load via await Gipity.auth.user(), and on signIn() / signOut() (including the <gipity-signin> button). It does not fire on subscribe (call user() yourself for the initial paint) and there is no background session-expiry signal - the SDK never polls, so nothing fires if the session is lost server-side without a signIn/signOut call in this tab. Don't write a polling loop waiting for one. There is also no gipity:auth DOM event on document/window; onChange is the subscription API. (The <gipity-signin> web component additionally emits a bubbling gipity-signin CustomEvent on the element itself, for that component's own button only.)

Verifying sign-in behind the login wall (--auth)

Pass --auth to gipity page inspect/screenshot/eval to load the page signed in as you (your Gipity account, which for your own app auto-consents), so UI and data behind a Sign-in-with-Gipity gate are reachable headlessly:

gipity page inspect "https://dev.gipity.ai/<acct>/<app>/" --auth          # inspect the signed-in page
gipity page screenshot "https://dev.gipity.ai/<acct>/<app>/" --auth        # capture the members-only UI
gipity page eval "https://dev.gipity.ai/<acct>/<app>/" --auth "App.commands().length"   # drive signed-in app code

How it works: the server mints a short-lived session for you and injects the gipity_sid cookie into the headless browser before it navigates - the same session a normal login produces, so your app needs no changes. You can only ever browse as yourself.

Without --auth the page loads as a genuinely anonymous, signed-out visitor - the browser's cookie jar is wiped before every navigation, so nothing carries over from an earlier --auth run. Each command's identity is exactly its flags, which is what makes the two-state check on an app with a signed-in/signed-out split cheap and safe: run once with --auth (the signed-in path) and once without (the anonymous path - the gate, the nudge, the public view). Every run prints an Auth: line naming which identity the page ran as. No Gipity.auth.signOut() workaround inside the eval body, and no risk of an "anonymous" check silently writing rows as the owner.

Scope: --auth covers apps that use Sign in with Gipity and are hosted on *.gipity.ai (dev/app). It does not cover apps with their own custom login, nor (for a page that reads a same-host cookie) custom domains. For those, verify the wiring instead: the gate renders, the button is wired, window.Gipity.auth exists, and Gipity.auth.status() is reachable - not the full popup; final confirmation needs a real browser with a signed-in user. Without --auth, the sandbox is headless with no logged-in user, so a signed-in round-trip can't complete there.

Signing out

Wire your sign-out button to await Gipity.auth.signOut() - it ends the Gipity session server-side (clears the session cookie) and locally, so the user stays signed out across reloads. Then refresh your UI / navigate home.

signoutBtn.addEventListener('click', async () => {
  await Gipity.auth.signOut();
  showSignedOut();          // re-render header + view for the signed-out state
});

Don't expect a one-liner local flag flip to log anyone out: the session lives in a shared .gipity.ai cookie, so anything that only clears in-memory state leaves the cookie live and Gipity.auth.user() re-detects the session on the next load - the user "comes back" signed in. signOut() is the only call you need; signOutEverywhere() is a deprecated alias for it.

Client Code - Popup Flow (low-level - prefer the SDK above)

Shown only to explain what happens under the hood; new apps should not hand-roll it.

async function checkAuth(appGuid, appToken) {
  const res = await fetch(\`https://a.gipity.ai/api/\${appGuid}/auth/status\`, {
    credentials: 'include',
    headers: { 'X-App-Token': appToken }
  });
  const data = await res.json();

  if (data.authenticated && data.consented) {
    return data.user; // Already authed
  }

  // Open login or consent popup
  const authUrl = (data.loginUrl || data.consentUrl) + '&mode=popup';
  const popup = window.open(authUrl, 'gipity_auth', 'width=450,height=600');
  return new Promise(resolve => {
    window.addEventListener('message', function handler(e) {
      if (e.data?.type === 'gipity_auth') {
        window.removeEventListener('message', handler);
        if (e.data.status === 'success') {
          // Re-check to get user profile
          checkAuth(appGuid, appToken).then(resolve);
        } else {
          resolve(null); // User denied
        }
      }
    });
  });
}

Client Code - Redirect Flow

Navigates away from the app. Use &return=<app_url> so users come back after auth.

const res = await fetch(\`https://a.gipity.ai/api/\${appGuid}/auth/status\`, {
  credentials: 'include',
  headers: { 'X-App-Token': token }
});
const data = await res.json();
if (!data.authenticated) {
  location.href = data.loginUrl + '&return=' + encodeURIComponent(location.href);
} else if (!data.consented) {
  location.href = data.consentUrl + '&return=' + encodeURIComponent(location.href);
}

Complete Example (low-level REST - the SDK is simpler)

This raw-REST flow is reference only; for real apps prefer the SDK section above. Note it does not include the popup gesture/COOP handling the SDK provides.

const API = 'https://a.gipity.ai';
const APP_GUID = '<PROJECT_GUID>';

// Step 1: Get app token
const tokenRes = await fetch(\`\${API}/api/token\`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ app: APP_GUID })
});
const tokenData = await tokenRes.json();
const appToken = tokenData.data.token;  // NOTE: .data.token, not .token

// Step 2: Check auth status
const authRes = await fetch(\`\${API}/api/\${APP_GUID}/auth/status\`, {
  credentials: 'include',
  headers: { 'X-App-Token': appToken }
});
const auth = await authRes.json();

if (!auth.authenticated) {
  // Open login popup
  const popup = window.open(auth.loginUrl + '&mode=popup', 'gipity_auth', 'width=450,height=600');
} else if (!auth.consented) {
  // Open consent popup
  const popup = window.open(auth.consentUrl + '&mode=popup', 'gipity_auth', 'width=450,height=600');
} else {
  // Step 3: Call an authenticated function
  const res = await fetch(\`\${API}/api/\${APP_GUID}/fn/my_function\`, {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json', 'X-App-Token': appToken },
    body: JSON.stringify({ param1: 'value' })
  });
  const result = await res.json();
}

Never let a signed-out user lose work at an auth wall (UX)

The one rule: when an action requires sign-in (auth_level: "user", user_pays, or "save your own X"), a signed-out user must never type work into a form and then lose it at Save. That dead-end - fill the form, click Save, get blocked, work gone - is the actual defect. When you ask them to sign in is a product call with two legitimate patterns; dropping their work is never one of them.

Decide what to render from the auth state you already fetched on load (Gipity.auth.user() / auth/status).

Don't - let them fill the form, then block at submit and drop the work:

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const user = await Gipity.auth.user();
  if (!user) { alert('Please sign in to save'); return; } // work already typed, now blocked and lost
  await Gipity.fn('add_recipe', { title: titleInput.value });
});

Pick one of these instead:

Pattern A - gate up front. Simplest, and right when the form is cheap to defer (nothing lost by waiting). Show a "Sign in to add your own" CTA in place of the create form for signed-out users:

const me = await Gipity.auth.user();
createForm.hidden = !me;     // signed in: show the form
signinGate.hidden = !!me;    // signed out: show the CTA instead (<gipity-signin> / "Sign in to add your own")

Pattern B - let them work, then preserve it through sign-in. Higher conversion - the user is invested by the time you ask (the "try it, then save" funnel Figma/Canva/CodePen use). The cost is you must persist the in-progress work across sign-in and replay the submit, so nothing is lost:

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const draft = { title: titleInput.value };
  let user = await Gipity.auth.user();
  if (!user) {
    sessionStorage.setItem('pendingRecipe', JSON.stringify(draft)); // stash before sign-in
    user = await Gipity.auth.signIn();                              // popup; work is safe
    if (!user) return;                                             // cancelled - draft still on screen
  }
  await Gipity.fn('add_recipe', draft);
});
// On load, restore any stashed draft so a redirect-based sign-in doesn't lose it:
const pending = sessionStorage.getItem('pendingRecipe');
if (pending) { titleInput.value = JSON.parse(pending).title; sessionStorage.removeItem('pendingRecipe'); }

Choose A for low-investment forms or when you want a clean signed-out state; choose B when the work itself is the hook and you want the funnel. Either is fine - silently losing the user's work is not.

Common Mistakes