Sign-in and sessions

The sign-in calls, where the code comes from, rate limits, sessions, sign-out and your own server.

Sign-in API

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 } }; the token is stored automatically

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

await g.auth.logout()          // ends this device's session; their other devices stay signed in
await g.account.export()   // GDPR access: everything the person holds, as one JSON document
await g.account.delete()   // GDPR erasure: sessions, records, files and plan are deleted; a paid plan in Stripe is set to cancel

Where the code comes from

Until you add a domain, the code comes from Gemmein's shared address as <App name> (via Gemmein). No other email leaves from Gemmein's shared address. Add your domain on the Domains page for email (or both). The page lists every record it needs, each with a status: the ownership TXT, the sending records and the receiving MX. Once they are found, the code comes from your domain's account address. Sender addresses, replies and the Inbox are covered in Email.

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; an IPv6 address counts by its /64). Codes live for 10 minutes; sessions for 30 days.

Three sends means the original plus two resends. If someone taps a "Didn't get it? Resend" button twice, they are locked out of their own sign-in for up to 15 minutes. An automatic resend on timeout makes this worse. Disable the button between attempts, show the countdown, and let them wait for the code, which is almost certainly in their inbox already. The 429 carries resetAt, so you can show the exact wait.

Sessions

Sessions persist across page reloads automatically (localStorage in browsers, memory elsewhere; pass a tokenStore for custom persistence). A person can be signed in on several devices at once: a sign-in adds a session, up to 10 per person, and the 11th signs the oldest out. A session lasts 30 days. A token whose session has ended throws auth_expired once with a sentence for the person ("You were signed out. Sign in again.") and the SDK clears it.

Route to your sign-in screen from one place, g.auth.onSignedOut(({ reason }) => showSignIn(reason)). It fires however the session ended: "signed_out" (they asked), "revoked" (signed out from another device or by you) or "expired" (its 30 days ran out). The SDK has already cleared its token.

currentUser() never throws for session state. Call it unguarded on app load. An expired or missing session resolves to { authenticated: false } and does not throw.

The two user shapes

Two calls return the signed-in person, in different shapes. This is the 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 }

On currentUser(), use user.userId. user.id is undefined there, and feeding it into a keyed create (key: "profile:" + user.id) puts every person on one profile:undefined record. Use currentUser().userId as the signed-in identity.

Sign out

The method is g.auth.logout(), and it ends this device's session only. There is no signOut: a guessed signOut() inside a try/catch never throws, and leaves the server session live.

From your own server

Gemmein runs none of your code. When your own function runs elsewhere (a model call, a PDF render, a nightly job), it can verify the session from any host: pass the browser's session token to gemmeinServer(sk).verifySession(token). One call returns who the person is and what they have access to.

const server = gemmeinServer("sk_...")   // on your server only
const { person, holdings } = await server.verifySession(token)
// person: { id, email, role }; holdings: what they have access to

Always verify the token; never trust a person id the browser sends you. A refusal names the reason (session_invalid, session_expired, session_revoked, person_suspended), so your function can tell "sign in again" apart from "you 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 comes from the server's own record); an id that is not one of your people returns 404 not_a_customer. It sends only from your own verified sender domain: until you verify one on the Domains page, every call returns 409 sender_domain_required and nothing is sent, recorded or counted against a cap. It never falls back to a Gemmein address.

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
})

Sender addresses, caps and replies are covered in Email. To email many people, or on a webhook, use a relay's email_person.

Was this page helpful?

Last updated