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:
- An app token (identifies your app to the API)
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:
- LOGIN_REQUIRED (401): User not logged into Gipity. Redirect to
error.loginUrl. - CONSENT_REQUIRED (403): User is logged in but hasn't granted permission. Redirect to
error.consentUrl.
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
- Reading
response.tokeninstead ofresponse.data.token- the token endpoint wraps its response in adataobject - Forgetting
credentials: 'include'- without this, the session cookie won't be sent on cross-origin requests toa.gipity.ai - Using the project GUID as the token - the GUID identifies your app, but you still need to call
POST /api/tokento get an actual bearer token - Using relative URLs - app code runs on
app.gipity.aiordev.gipity.ai, so API calls must use the full URLhttps://a.gipity.ai/...