guide

Auth & sessions

Users sign in with a one-time email code. There are no passwords anywhere in the system. No password database exists, so there is nothing to leak in a breach.

The whole surface

await g.auth.sendEmailCode("user@example.com")   // emails an 8-digit code

const session = await g.auth.verifyEmailCode({ email: "user@example.com", code: "12345678" })
// { token, expiresAt, user: { id, email } } — token stored automatically

const user = await g.auth.currentUser()
// { authenticated: true, userId: "usr_...", email: "user@example.com" }

await g.auth.logout()          // revokes the server session
await g.account.delete()   // GDPR erasure: sessions, records, files, subscription — all gone

Where the code comes from

Until you add a domain, the code arrives from Gemmein's shared address as <App name> (via Gemmein) — the one email Gemmein sends on your behalf, and the only one. Add your domain on the Domains page with a purpose of email (or both) and you are shown every record it needs at once, in one list, with its own status: the ownership TXT, the sending records, and the receiving MX. When they are found, the code arrives from you.

A verified domain carries three sender words, set on its page in the console: account, support and news. A sign-in code is account activity, so it leaves from the account word; notify() of kind: "event" leaves from news; your own replies from the Inbox leave from support. Each one is a real address — there is no no-reply here. A customer who answers a code reaches your Inbox, in a conversation labelled by the word they wrote to, so “I didn't ask for this” never arrives looking like a billing question. Two kinds can never share one word; the console refuses it.

Sessions

Sessions persist across page reloads automatically (localStorage in browsers, memory elsewhere; override with tokenStore for custom persistence). One session per user: verifying a new code revokes that email's older sessions, on every surface — a sign-in on a phone signs the browser out, and the other way round. A stale token throws auth_expired once, the SDK clears it, and a retry (or re-auth) recovers.

currentUser() never throws for session state. Call it unguarded on app load: an expired or missing session resolves to { authenticated: false } rather than an error.

From your own server

A session can be verified from any host. Gemmein runs no code of yours, so when a function of yours runs elsewhere (a model call, a PDF render, a nightly job), it takes the browser's session token, calls gemmeinServer(sk).verifySession(token), and gets back who the person is and what they hold, in one call. Never trust a user id the browser sends you; verify the token. The refusal names the reason (session_invalid, session_expired, session_revoked, person_suspended), so your function can tell "sign in again" apart from "the owner turned this person off". See the SDK reference for the server client's methods.

Emailing your people

notify() emails one verified person of your app, by id, never by address — the address is the server's own record, and an id that is not one of your people is 404 not_a_customer. It sends only from your own verified sender domain: until one is verified on the Domains page every call is refused 409 sender_domain_required — nothing leaves, nothing is recorded, no cap is spent, and the same key sends once you verify. It never falls back to a Gemmein address; the sign-in code above is the one email Gemmein sends on your behalf. kind: "event" (the default, 5 per person per day) leaves from your domain's news word; kind: "account" (security and account notices, no per-person cap) from its account word; both count toward 200 per app per hour. Every send lands in your Inbox as a conversation, and the customer's reply comes back to it. The same thing to many people, or on a webhook, is a relay's email_person.

await server.notify("usr_abc123", {
  subject: "Your order shipped",
  text: "Order #142 left the warehouse today.",
  key: "order-142-shipped",   // retries can't double-send
})

Rate limits and the resend button

Sending a code and verifying a code are each capped at 3 per email per 15 minutes (plus 20 and 30 per IP respectively). Codes live for 10 minutes; sessions for 30 days.

Three sends means the original plus two resends. A “Didn't get it? Resend” button that a frustrated person taps twice locks them out of their own sign-in for up to 15 minutes, and an automatic resend-on-timeout makes it strictly worse. Disable the button between attempts, show the countdown, and let them wait for the code that is almost certainly already in their inbox. The 429 carries resetAt, so you can render the exact wait rather than guessing.

The two user shapes differ

Two calls give you "the user", and their shapes differ. This is the single most common auth mistake in AI-written code:

// verifyEmailCode resolves the SESSION — the user is nested, and the field is `id`:
{ token, expiresAt, user: { id, email } }

// currentUser resolves the IDENTITY — flat, and the field is `userId`:
{ authenticated: true, userId, email }

It's user.userId, not user.id, on currentUser(). user.id is undefined there, and feeding it into a keyed create (key: "profile:" + user.id) collapses every user onto one profile:undefined record. Use currentUser().userId as the canonical signed-in identity.

Sign out

The method is g.auth.logout(); there is no signOut. A guessed signOut() inside a try/catch never throws, and leaves the server session live.

Account deletion

account.delete() erases the signed-in user in full: sessions revoked, their records and files deleted, their subscription row removed. This is the user-side GDPR erasure path, and it needs no dashboard involvement. It is irreversible, so put a real confirm in front of it. Note that currentUser() answers authenticated: false identically for signed-out, suspended, and erased users, and it does so on purpose: a moderated user's state never reaches the client, and one signed-out screen covers all three.