Auth and teams
How sign-in, passkeys, sessions, teams, roles and invitations work with Better Auth.
Authentication uses Better Auth with its organization plugin. In Emberstack an organization is a team.
How it's wired
src/lib/betterauth.tsconfigures Better Auth with the Drizzle adapter on D1, email and password sign-in, and the organization, admin, passkey, MCP and Stripe plugins. It importsbetter-auth/minimal, which leaves out the database driver the Drizzle adapter doesn't need.- Sessions are cached in a signed cookie for five minutes, so most page views skip the session read. Writes and the superadmin area always read the database, so a ban or revoked session takes effect at once.
- Passwords are hashed with PBKDF2 through Web Crypto, which is built into Workers and fast enough for their CPU limits.
src/pages/api/auth/[...all].tshands every/api/auth/*request to Better Auth.src/middleware.tsreads the session on each request and sets the request locals below.
Local | Value |
|---|---|
| The signed-in user, with a numeric |
| The Better Auth session, or |
| The team the user is working in |
| The user's role in that team: |
Teams
When someone signs up, Emberstack creates a team named after them and makes them its owner. The team switcher in the dashboard changes the active team, and middleware always checks the user is still a member before it trusts that choice.
People are invited from the Team page. The invite form uses the inviteTeamMember action in src/actions/auth.ts.
Passkeys
People can sign in with a passkey (fingerprint, face or device PIN) as well as a password, through the Better Auth passkey plugin.
- They add, rename and remove passkeys under Settings → Passkeys. Adding needs JavaScript; renaming and removing are plain forms.
- The login page has a Sign in with a passkey button, and browsers that support it offer passkeys when you focus the email field.
- Passkeys are tied to the domain in
PUBLIC_URL, so ones made on your domain don't work on theworkers.devaddress. - Deleting an account deletes its passkeys and refuses new sessions for it. Bans apply to passkey sign-ins too.
AI clients (MCP)
Better Auth is also an OAuth server for AI clients such as Claude, which connect to /api/mcp. See MCP server.
Protecting pages
Every page and endpoint under /app checks for a user itself. There's no global guard.
---
if (!Astro.locals.user) return Astro.redirect('/login');
if (Astro.locals.memberRole !== 'owner') return new Response('Forbidden', { status: 403 });
---Check roles on the server. Hiding a button in the UI isn't a permission check.
Calling Better Auth
Use auth.api.* from pages and endpoints, and pass the request headers so it acts as the current user:
import { auth } from '@/lib/betterauth';
await auth.api.changePassword({
headers: Astro.request.headers,
body: { currentPassword, newPassword },
});When a call needs to set cookies, such as sign-in or sign-up, pass asResponse: true and copy the response headers onto yours. src/pages/login.astro shows how.
Adding providers and plugins
Look the plugin up in the current Better Auth docs, add it in betterauth.ts and its client plugin in betterauth-client.ts. If it adds tables or columns, mirror them in src/db/schema.ts and run a migration (see Database). OAuth providers go in socialProviders.