Overview
Litestore signs people in with Better Auth and checks who they are on every server request.
Litestore uses Better Auth to sign people in. Read this page when you change how users log in, protect a new route, or need to find out why a ban takes effect right away.
Better Auth runs on a Prisma adapter, and Litestore mounts it at app/api/auth/[...all]/route.ts. Litestore registers exactly two plugins, magicLink and admin. There is no third, and that short list is the reason the sign-in surface is so narrow.
Sign-in methods
In platform/auth/server.ts, the magicLink plugin gets a sendMagicLink callback. The callback renders modules/email/templates/magic-link-simple.tsx and passes it to sendEmail:
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
const subject = `Your ${config.site.name} Login Link`
await sendEmail({ to: email, subject, react: EmailMagicLink({ to: email, url }) })
},
}),
// …
]sendEmail in modules/email/send.ts sends the message through Resend. env.ts declares RESEND_API_KEY and RESEND_SENDER_EMAIL as required in production and optional elsewhere.
Outside production, sendEmail never calls Resend, even when a key is set: it logs the message, including the sign-in link, to the server console and records it in EmailLog, so local development works without Resend. In production Resend is a hard requirement, because the magic link is the only sign-in route that is always available.
There is no email-and-password sign-in
platform/auth/server.ts never sets emailAndPassword, so Better Auth leaves it turned off. Without Google, sign-in is magic link only.
Sign-in screen
The sign-in screen offers the same two routes. modules/customers/storefront/auth/login.tsx renders LoginForm, an email field that calls signIn.magicLink, and LoginButton, a button that calls signIn.social({ provider: "google" }).
When the email form succeeds, it sends the user to getVerifyUrl(email), which is /auth/verify?email=….
Rate limits
The before hook is a createAuthMiddleware. It rate-limits the one sign-in endpoint Litestore enables, by the caller's IP from getIP(). The limiter is defined in platform/rate-limit/limiters.ts:
| Path | Preset | Limit |
|---|---|---|
/sign-in/magic-link | rateLimits.authMagicLink | 3 per minute |
When the limiter blocks a request, it answers with a plain Response.json and a Retry-After header. That response does not use Better Auth's own error format. The client receives a 429 whose message is undefined, so a component that only displayed the error field would show the user nothing.
authErrorMessage in platform/auth/errors.ts turns that response into readable text. It is the only code that explains a blocked send, and every auth onError in the login components goes through it.
If Redis is unreachable, checkRateLimit lets the request through. A Redis outage therefore never locks users out of sign-in.
Sessions
A session lasts 7 days and refreshes after 24 hours. Better Auth also caches it in a cookie for the full 7 days, so the role and ban flag stored in the session can be days behind the database.
Litestore accepts that delay but never relies on those copies. No code that makes a real decision reads the role or ban flag from the session.
On every request, getSession checks that the user row still exists. getFreshUserById reads the user from the database, wrapped in cache() so it runs once per request:
export const getFreshUserById = cache((id: string) =>
db.user.findUnique({
where: { id },
select: {
id: true,
role: true,
banned: true,
banExpires: true,
grantedPermissions: true,
deniedPermissions: true,
},
}),
)The cache() wrapper keeps this cheap, because four callers share the one query in each request:
| Caller | Where | Why it reads the user |
|---|---|---|
| Session check | getSession | Confirms the session's user still exists. |
| Admin layout | app/admin/layout.tsx | Re-reads the role and ban flag. |
getPermissionContext | platform/permissions/check.ts | Builds the user's permissions. |
resolveAccountStage | modules/customers/stage.ts | Handles the ban-expiry branch. |
As a result, a ban takes effect on the user's next request instead of when their session ends.
getSession
getSession() in platform/auth/server.ts wraps Better Auth's auth.api.getSession and is cached per request. zsa server actions, route handlers and direct server calls all call it to find out who is signed in, and nothing else should call auth.api.getSession() directly.
Middleware
middleware.ts only checks whether a session cookie is present, using getSessionCookie. If there is no cookie, it redirects requests for /admin and /dashboard to /auth/login?next=…. If there is one, it sends signed-in users away from /auth.
Middleware does not determine what a user may do. A cookie being present proves nothing about who the user is. The admin layout, the page guard and the permission procedures make those checks.
Customer records
The after hook creates the storefront Customer record on the OAuth callback (/callback/:id), which also calls revalidatePath on the page it redirects to.
It calls ensureCustomerExists in modules/customers/ensure-customer.ts. This function finds the matching customer or creates and reconciles one, and it is the only place that does so.
That path doesn't run on someone's first magic-link sign-in. For that case, getSessionCustomerId in modules/customers/session-customer.ts creates the customer lazily through the same ensureCustomerExists.
The hook does not send the welcome email. Instead, ensureCustomerExists emits a customer.created event, and jobs/domain-events.ts sends the email when it handles that event.
Sending the email from the event fixed two bugs. Before, sign-up sent the welcome email twice and OAuth never sent it. Now one function emits one event, whichever path the user came through.
Impersonation audit
When an admin signs in as another user, the after hook logs it. The log happens on the server and not in any client code, so nobody can skip it.
Better Auth sets session.impersonatedBy on the impersonated session. The hook reads that field and writes a user_impersonated activity entry:
if (path === "/admin/impersonate-user" && newSession) {
const impersonatedBy = (newSession.session as { impersonatedBy?: string | null })
.impersonatedBy
if (impersonatedBy) {
await emitActivity({
type: "user_impersonated",
userId: impersonatedBy,
description: `Started impersonating ${newSession.user.email ?? newSession.user.id}`,
metadata: {
module: "admin",
action: "impersonate_start",
actorId: impersonatedBy,
targetUserId: newSession.user.id,
targetEmail: newSession.user.email,
},
}).catch(() => {})
}
}When the admin calls /admin/stop-impersonating, the hook writes a matching entry with action: "impersonate_stop" and the admin's own id.
Both calls end in .catch(() => {}), so a failed audit write does not stop the session from switching. The audit is best-effort, because otherwise a logging failure could leave an admin stuck inside someone else's session.
Admin plugin access
Litestore registers the admin plugin with adminRoles: ["owner"] and allowImpersonatingAdmins: false. The plugin's role map gives real permissions to the owner and none to anyone else:
const betterAuthOwnerRole = defaultAc.newRole({
user: [
"create", "list", "set-role", "ban", "impersonate",
"delete", "set-password", "get", "update",
],
session: ["list", "revoke", "delete"],
})
const betterAuthNonOwnerRole = defaultAc.newRole({ user: [], session: [] })Every other role maps to the empty role. That covers admin, manager, editor, support, marketing, analyst, collab and user. Only the owner can use Better Auth's own endpoints for managing users and sessions.
That is separate from access to the admin. Litestore controls access to each admin module with a different system, described in Permissions. Here, Better Auth only controls its own user and session endpoints.
Route handler protection
platform/auth/with-admin-auth.ts exports withAdminAuth(handler, permission). It first requires a session (401 without one), then checks that the fresh permission context has an admin role, that resolveAccountStage() doesn't report banned, and that checkPermissionWithContext allows the named permission (403 otherwise).
The permission argument is required. An API route cannot stop at a broad "is this an admin?" check, because the function signature does not allow it. Five route handlers use it:
app/api/upload/route.ts
app/api/media/route.ts
app/api/admin/collections/picker/route.ts
app/api/ai/models/route.ts
app/api/capabilities/route.tsService keys
platform/auth/service-keys.ts handles callers that use a key instead of a session. validateServiceKey reads a Bearer token from the authorization header and looks it up in two places.
Scoped keys
It calls validateApiKey in modules/team/api-keys/keys.ts.
SERVICE_API_KEY
If no stored key matches, it compares the token with the SERVICE_API_KEY env value. A request that matches the env value always gets the scopes read, write:orders and write:webhooks.
requireServiceKey throws AuthError with code UNAUTHORIZED instead of returning null, so a caller cannot carry on by accident with a null key.
hasScope(scopes, required) checks whether a key has a scope. AuthError extends AppError, and its code is either UNAUTHORIZED or FORBIDDEN.
Browser client
platform/auth/client.ts builds the browser client with adminClient() and magicLinkClient(). It exports signIn, signOut, useSession, admin, linkSocial, unlinkAccount and revokeOtherSessions.
In the browser, its baseURL is window.location.origin. On the server, it is env.NEXT_PUBLIC_SITE_URL. This split fixes a real bug. When the public URL was hardcoded, every session request from a localhost dev server went to production.
platform/auth/session-types.ts defines AppSession and AppSessionUser. The file imports nothing, so client bundles can use it without pulling in server code.
Related
Permissions
Litestore turns an admin role into effective permissions, lets grantedPermissions and deniedPermissions override it, and wraps each server action or page in a permission procedure.
Keys
Litestore mints, hashes and revokes service API keys, and each enforced scope permits a specific set of calls.
Litestore renders, queues and sends email through Resend from the React Email templates in modules/email/templates/, and the EmailLog ledger records every send.