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
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.
On a provider's first sign-in to an app, the platform links the current guest identity to the named
account. A returning provider account resumes its established appUserId. The current runtime does not
reassign rows created by a fresh guest session before that returning sign-in, so do not promise that
those rows will follow the account.
#ctx.auth
interface AuthIdentity {
/** @deprecated Equal to appUserId. */
readonly id: string;
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. 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. To get a user's email, ask for it in a form like any other data.
This is structural, not 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.
Remote preview and production share the application's immutable appId, but use separate data stores
and host-only browser cookies. A named provider account resolves to the same pairwise appUserId after
sign-in on either origin. A cold guest on the other origin is a new guest. Local xeer preview is
different because it uses deterministic development personas.
#Signing in
Sign-in lives on your app's own origin, at /_xeer/auth/sign-in. Two providers ship today: Google,
and an email and password account that Xeer holds on the person's behalf — so you can offer a way in
to someone who has no Google account and does not want one.
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, Google
signIn({ returnTo: '/dashboard' });
signIn({ provider: 'password' }); // Xeer's own email and password form
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: a plain /_xeer/auth/sign-in restores the startup default selected by
xeer auth as, including workspace membership. Passing an explicit persona creates a new
selection and clears membership unless workspace ids are supplied too. See
Local personas. A provider is ignored
there for the same reason: locally there is neither a Google to redirect to nor a password to check.
#Email and password
Ask for the password provider by name, and the visitor gets Xeer's own sign-in form instead of Google's:
import { SignInButton, signIn } from '@impetik/xeer/client';
<SignInButton provider="password">Sign in with email</SignInButton> // the component
signIn({ provider: 'password' }); // or drive it yourselfprovider takes 'google' (the default, and what you get when you omit it) or 'password'. The
button's prop is typed from the function's options, so the two cannot drift apart; anything outside the
pair is a compile error, and would be a 400 from the auth service if it ever got past the type. A
plain link works too, when there is no component around to hold a prop:
<a href="/_xeer/auth/sign-in?provider=password">Sign in with email</a>The password option currently works only with the Preact adapter. The React adapter accepts Google
sign-in but does not forward provider="password". This is a known adapter defect, not a second auth
contract.
One thing to know before you write the copy: all three forms open the sign-in form, and there is no
option that opens sign-up directly. That form carries a "No Xeer password yet? Create an account" link,
so registering is one click further on, not zero. Label the control "sign in" rather than promising a
registration form, and do not go looking for a mode option — it is deliberately not part of the
public surface.
They type their address and password on auth.xeer.run, never on your origin. That is deliberate: a
password field on your page is readable by your own JavaScript, and the point of the boundary is that
your app never touches a credential. They return the way a Google sign-in returns — same callback, same
session, same ctx.auth. Your code cannot tell which door someone used, and still never sees an
email address.
What to tell your users, since you are writing the copy around the button:
- A password is at least 12 characters and at most 256. There are no composition rules — no required digit, symbol, or capital letter. Length is the whole policy.
- Changing a password signs out every other session for that account: their other devices, and anyone who had got hold of the old password. The session they changed it on stays alive.
- There is no password reset yet. Xeer does not send email in this release, so there is no "forgot password" link to point them at — and the address does not free up. Signing up again with it is refused, because the account already exists; someone who forgets their password has to start over with a different address, and their old one stays unusable for this release. Worth weighing before you make it the only door.
A Google account and a password account with the same address are two different
accounts. They get different appUserIds and see different rows, exactly like two
strangers. Xeer will not merge them, because an address that neither provider has proved to the other is
not evidence that one person owns both — treating it as evidence is how accounts get stolen. If you
offer both doors, expect someone to walk through the wrong one and find their notes missing; the
simplest cure is to offer one.
Do not use the exported AuthManagementPanel in this release. Its client model expects fields that the
app-scoped management response correctly redacts, so it can fail while rendering. The account routes
support session revocation, data export, and deletion, but the bundled panel is a known SDK defect.
#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. Keep the field required in the manifest: the generated insert type makes only the policy-managed field optional, while stored rows remain non-nullable. - 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's writes — insert, update and delete. Reads are not refused; they stay scoped to that guest's own rows like anyone else's. 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' })
workspaceTable({ workspaceField: 'id' }) // on a table with "idSource": "application"The values compared are your identity provider's workspace ids, exactly as they arrive in
ctx.auth.workspaceIds. A column holds one only because you put it there — which is why the third
form exists. Declare "idSource": "application" on a workspaces table and register each row at
the workspace id it describes, and the row and the membership become one string: the policy scopes
the table by its own primary key, a ref to it is a real foreign key, and there is no second
"external key" column to maintain and join through. See
Who chooses the id.
- 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. As with
ownedTable(), that covers the writes; a guest's reads are filtered by membership, and a guest holding none reads an empty list.
#Validated at boot
The field a policy names must be a text column on that table: a declared string, a declared
ref — which holds another row's id — or id itself, and id only when the table declares
"idSource": "application". On a table whose ids the runtime mints, id is a uuid that is never an
identity anyone carries, so a policy naming it could only ever deny, and it is refused as the mistake
it is. Anything else and 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.
#A table with no policy
Most tables have none. It is what you get until you name one in authPolicies, and it is a real design
rather than a gap — so it is worth knowing exactly what it means.
What the data layer stops doing. All of it. Reads add no scoping predicate, so all() returns the
whole table to any caller. An insert stores what you passed and stamps nothing. An update or delete
finds the row by id and re-checks nothing about it. ctx.auth is never consulted on that table's
behalf.
What still runs. Everything else, unchanged. Operation guards are a separate mechanism:
authenticated(), memberOf(), hasRole(), hasPermission() and owner() all run before your
handler exactly as they would on a policied table. Your handler's own filtering works as written. An
unpolicied table reached only through guarded operations is protected. An unpolicied table whose
handlers filter by ctx.auth.appUserId is protected.
What you give up is the 2am guarantee at the top of this section: a policy holds in a handler that forgot to filter, and a guard plus handler code does not. So the question is not is this table protected — it is who is responsible for scoping it, the platform or you.
Sometimes it has to be you. examples/link-shortener leaves its links table unpolicied on purpose,
because resolving a short slug has to reach every owner's rows. Its management routes compare
link.ownerId to ctx.auth.appUserId themselves.
#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. A new approval defaults to Google. An existing password portal session can approve too, but first-time password builder provisioning needs an invitation pinned to the password provider. Established builders no longer consult the invitation. An uninvited identity creates nothing and tells the terminal to stop waiting. 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.