Auth
There are two completely separate identities in a Xeer project, and almost every confusion about auth comes from running them together. They share nothing: not a store, not a credential, not a login.
#Door 1 — you
You are a builder. You sign in so that you can deploy.
xeer auth loginYour credential lives on your machine. It authorises xeer deploy, xeer env set, xeer rollback — the
commands that change what is live. It never appears in your application's code, and your app never sees
it.
#Door 2 — your app's users
The people who visit the app you deployed are app users. They never sign in to Xeer; they sign in to your app, or they do not sign in at all.
ctx.auth.appUserIdTheir identity arrives at your server already verified, on every request. You do not implement sign-in, manage sessions, or check a token.
Nothing crosses between them. Your builder account is not in ctx.auth and cannot be. Signing in to
your own deployed app makes you an ordinary app user of it, with an id unrelated to your builder account.
Your users need no Xeer account of any kind — the closed beta gates deploying, not visiting.
The rest of this page is Door 2, because that is the one you write code against. Door 1 is one section at the end and the CLI reference.
#Door 2: your app's users
#Every visitor already has an identity
This is the part that surprises people. A deployed Xeer app gives every visitor a verified, stable
identity on their very first request, before any sign-in. They are a kind: 'guest', and they have a
real appUserId you can store as an owner.
So this works with no auth UI at all, for anyone who opens your app:
'notes.list': query({
input: {},
handler: (ctx) => ctx.db.table('notes').find({ where: { ownerId: ctx.auth.appUserId } }),
}),That is why xeer new ships no sign-in UI: the scaffolded app already scopes every note to its
caller. You add sign-in when you want a named account — something the person can return to from another
device, or deliberately end.
And when a guest does sign in, the platform links their existing identity to the new one, so the rows they created as a guest stay theirs. There is no "log in to keep your work" cliff to design around.
#ctx.auth
interface AuthIdentity {
readonly appUserId: string; // this caller, in this app
readonly kind: 'guest' | 'user';
readonly roles: readonly string[];
readonly permissions: readonly string[];
readonly workspaceIds: readonly string[];
readonly sessionId?: string;
}That is the whole surface, and what is absent is deliberate. Your app never receives an email address, a display name, a provider name, a provider account id, a global cross-app identity, an OAuth token, or the session cookie. If you want a user's email, ask them for it in a form like any other data — the platform will not hand it to you.
This is enforced structurally rather than by convention: identity reaches your code only as a cryptographically signed assertion your code cannot mint, and the platform strips every identity header and session cookie before your Worker sees the request.
#appUserId is pairwise
appUserId identifies the caller inside this app, and nowhere else.
- Same person, same app, any session or device → the same id. Stable, so ownership survives signing out and back in.
- Same person, two different Xeer apps → two unrelated ids. They cannot be correlated. Your app cannot learn that its user is also a user of somebody else's app, and neither can anybody else's app learn that about yours.
- A guest who has not signed in gets a per-visit identity. Signing in links it forward.
Store it as your ownerId. Never store an email as the identity key.
#Signing in
Sign-in lives on your app's own origin, at /_xeer/auth/sign-in. The provider that ships today is
Google.
The framework gives you three components so you do not have to build a flow to see one working:
import { SignInButton, UserButton, useAuth } from '@impetik/xeer/client';
function Account() {
const { isAuthenticated, loading } = useAuth();
if (loading) return <span>checking identity…</span>;
return isAuthenticated ? <UserButton /> : <SignInButton>Sign in</SignInButton>;
}Or drive the route yourself:
import { signIn, signOut, useAuth } from '@impetik/xeer/client';
const { refresh } = useAuth();
signIn(); // full-page navigation
signIn({ returnTo: '/dashboard' });
void signOut().then(() => refresh()); // signOut does not refresh useAuth for youuseAuth() returns { auth, loading, error, isGuest, isAuthenticated, refresh }. Use it to decide what
to render. Never use it as authorization — the server decides what is allowed, and a client can
claim anything.
Under xeer dev the same button behaves differently, because there is no
provider to talk to: /_xeer/auth/sign-in signs the session in as the local persona
alice and clears any workspace membership. So locally the button reads as “become alice”,
which is worth knowing before you conclude it is broken. See
Local personas.
Signed-in users also get a platform-provided account panel — their sessions, a data export, and account deletion — which you can drop in as one component:
import { AuthManagementPanel } from '@impetik/xeer/client';Revoking sessions revokes the whole session family, so a refreshed descendant session dies with it.
#Authorization
Identity tells you who. Authorization decides what. Xeer gives you two mechanisms at two levels, and you should use both.
| Table policies | Operation guards | |
|---|---|---|
| Declared in | defineServer({ authPolicies }) | a query/mutation/endpoint's auth |
| Applies to | every read and write of that table | one operation, before its handler runs |
| Protects against | a handler that forgot to filter | a call that should not have been made |
A table policy is the stronger guarantee, because it holds even in the handler you wrote at 2am.
#Table policies
import { defineServer, ownedTable, workspaceTable } from '@impetik/xeer/server';
export default defineServer({
authPolicies: {
drafts: ownedTable(),
cards: workspaceTable(),
},
// …
});#ownedTable()
Rows belong to one user, identified by a string field on the table.
ownedTable() // ownerField: 'ownerId', allowGuests: true
ownedTable({ ownerField: 'authorId' })
ownedTable({ allowGuests: false })- Reads are filtered to the caller in the database, so
ctx.db.table('drafts').all()returns only their rows and there is no window in which more existed. - Inserts stamp the owner field with the caller's
appUserIdwhen you omit it, and refuse any other value. - Updates and deletes are refused unless the row already belongs to the caller, and refused if the change would hand it to someone else.
allowGuests: falserefuses akind: 'guest'caller outright. It defaults totrue, because a guest is a real user with real rows.
#workspaceTable()
Rows belong to a workspace, and the caller must be a member.
workspaceTable() // workspaceField: 'workspaceId', allowGuests: false
workspaceTable({ workspaceField: 'teamId' })- Reads are filtered to
ctx.auth.workspaceIds. A caller with no membership reads an empty list immediately — no query runs, so this is fail-closed rather than fail-empty by luck. - Inserts fill the field automatically when the caller belongs to exactly one workspace. With several, the insert must name one they belong to, or it is refused.
- Updates and deletes are refused for a row outside the caller's workspaces, and refused if the change would move it out.
- Guests are refused by default, because a workspace is something you are put into.
#Validated at boot
The field a policy names must be a declared string field on that table. If it is not, the app refuses to start rather than serving unprotected rows. A typo in a policy is a failed deployment, not a leak.
#Operation guards
import { authenticated, hasPermission, hasRole, memberOf, owner } from '@impetik/xeer/server';| Guard | Passes when |
|---|---|
authenticated() | ctx.auth.kind === 'user' |
memberOf('workspaceId') | the named input field is a string in ctx.auth.workspaceIds |
hasRole('admin') | ctx.auth.roles includes it |
hasPermission('cards:write') | ctx.auth.permissions includes it |
owner() | the input's ownerId (or appUserId) equals ctx.auth.appUserId |
Attach one, or an array — an array requires all of them:
'cards.add': mutation({
input: { title: 'string', workspaceId: 'string' },
auth: [authenticated(), memberOf('workspaceId')],
handler: (ctx, input) => ctx.db.table('cards').insert(input),
}),Guards run before your handler, so a refused call never touches the database. A denial reaches the caller
as HTTP 400 with code: 'operation_failed' — identical to a refusal you threw yourself, so an attacker
cannot distinguish "forbidden" from "does not exist".
owner() reads the hard-coded input fields ownerId and appUserId; it is not configurable.
memberOf reads a top-level string field of the input by name.
#A policy filters; a guard refuses
This distinction catches people, so it is worth being explicit about.
| The caller is not a member, and the query… | They get |
|---|---|
relies on workspaceTable() alone | an empty list |
also carries memberOf('workspaceId') | a refusal (400 operation_failed) |
Both are safe: neither can return another workspace's rows. They are different products, so pick on purpose. A bare policy suits reads, because an empty list is usually what a UI wants to render. A guard suits writes, because "you may not do this" is the honest answer to an attempted change — and it is cheaper, since a guard refuses before the handler runs at all.
#Prove it
Every rule on this page is assertable, as three different users, in the runner that ships with the framework:
import { as, expect, expectFailure, test } from '@impetik/xeer/test';
test('two members of a workspace see each other rows', async () => {
const alice = as({ name: 'alice', workspaceIds: ['design'] });
const bob = as({ name: 'bob', workspaceIds: ['design'] });
const card = await alice.mutation('cards.add', { title: 'Ship it', workspaceId: 'design' });
// Membership, not ownership, scopes a workspaceTable(). Bob never touched this row.
expect((await bob.query('board.cards', { workspaceId: 'design' })).map((row) => row.id))
.toContain(card.id);
});
test('a non-member reads an empty board rather than another workspace', async () => {
const alice = as({ name: 'alice', workspaceIds: ['design'] });
const outsider = as({ name: 'bob', workspaceIds: ['ops'] });
await alice.mutation('cards.add', { title: 'Design only', workspaceId: 'design' });
// `board.cards` has no guard, so the policy filters: an empty list, never a refusal.
expect(await outsider.query('board.cards', { workspaceId: 'design' })).toEqual([]);
// The guarded *write* is a refusal instead.
const refused = await expectFailure(
outsider.mutation('cards.add', { title: 'Sneaky', workspaceId: 'design' }),
);
expect(refused.code).toBe('operation_failed');
});See Testing, and Local personas for the same identities in a browser.
#Door 1: your builder account
The other door, briefly. This is you, not your users.
xeer auth login # opens a browser, prints a confirmation code
xeer auth status # who you are, when the credential expires, which control plane
xeer auth logout # revoke it, and delete it locallyxeer auth login prints a verification URL and a confirmation code, then opens your browser. Approve
only if the browser shows that exact code — approval never happens on a plain page load. The credential
is written to ~/.xeer/credentials/cli.json with 0600 permissions and lasts 30 days;
xeer auth logout revokes it immediately.
Xeer is in closed beta, so builder accounts are by invitation. Signing in with an address that has not been invited creates nothing and tells the terminal to stop waiting, rather than leaving it polling. See the FAQ.
xeer auth as and xeer auth clear are not this door. They sit
under xeer auth for historical reasons, but they select the local development
persona — a pretend app user — and touch no account and no credential. See
Local personas.
#Next
- Local development — personas, and exercising all of this offline.
- Testing — asserting the refusals above.
- Server functions and the database — where
ctx.authis used. - The client —
useAuth,SignInButton,UserButton.