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.