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

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.

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>' });
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);

Once signed in, Gipity.fn(name, body) and Gipity.upload(file) auto-attach the token. 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