Overview
This page shows how people sign in to Litestore and how the server checks who they are on every 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 know why a ban takes effect right away.
Better Auth runs on a Prisma adapter and is mounted 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.
A shopper signs in by clicking a link that arrives by email. When Google credentials are configured, they can also sign in with Google.
The sections below cover the sign-in routes that exist, how the server re-checks a user on every request, and what the activity log records when an admin impersonates someone.
The two ways in
In lib/auth.ts, the magicLink plugin gets a sendMagicLink callback. The callback renders emails/magic-link-simple and passes it to sendEmail:
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
const to = email
const subject = `Your ${config.site.name} Login Link`
await sendEmail({ to, subject, react: EmailMagicLink({ to, url }) })
},
}),
// …
]sendEmail in lib/email.ts sends the message through Resend.
env.ts declares RESEND_API_KEY and RESEND_SENDER_EMAIL as required strings, not optional ones.
This makes Resend a hard requirement rather than an optional integration. Without Resend the magic link never arrives, and the magic link is the only sign-in route that is always available.
Google is the second way in, and Litestore only registers it when it is configured:
// Register Google only when configured — a self-hoster can run email/password only.
socialProviders:
env.AUTH_GOOGLE_ID && env.AUTH_GOOGLE_SECRET
? { google: { clientId: env.AUTH_GOOGLE_ID, clientSecret: env.AUTH_GOOGLE_SECRET } }
: {},Both env vars are z.string().optional(). The check needs both of them. If you set only one, socialProviders stays empty, and Litestore behaves as if Google were not configured at all.
account.accountLinking is enabled. When someone signs in with Google using an email that already has an account, Better Auth links Google to that account instead of creating a second one.
Users can later connect or disconnect Google in components/web/dashboard/settings/account-security.tsx, which calls linkSocial and unlinkAccount.
There is no email-and-password sign-in
lib/auth.ts never sets emailAndPassword, so Better Auth leaves it turned off. The comment above socialProviders says a self-hoster "can run email/password only", but the configuration does not allow that.
The before hook still carries the same old assumption. It rate-limits four endpoints that are not enabled:
/sign-in/email
/sign-up/email
/forget-password
/reset-passwordThose branches protect nothing. They are dead code that only the rate limiter still refers to.
The sign-in screen
The sign-in screen offers the same two routes. components/web/auth/login.tsx renders two pieces:
LoginFormis an email field that callssignIn.magicLink.LoginButtonis a button that callssignIn.social({ provider: "google" }).
When the email form succeeds, it sends the user to getVerifyUrl(email), which is /auth/verify?email=….
Rate limits on the auth endpoints
The before hook is a createAuthMiddleware. It limits requests by the caller's IP from getIP() together with the Better Auth path.
The limits are defined in lib/rate-limit/core.ts:
| Path | Preset | Limit |
|---|---|---|
/sign-in/email, /sign-in/credentials, /sign-up/email | rateLimits.authLogin | 5 per minute |
/sign-in/magic-link | rateLimits.authMagicLink | 3 per minute |
/forget-password, /reset-password | rateLimits.authPasswordReset | 3 per hour |
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 lib/auth/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 and the fresh user read
A session lasts 7 days and refreshes after 24 hours. Better Auth also caches it in a cookie for the full 7 days.
Because the cookie cache lasts that long, 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, getServerSession 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. Four callers share the one query in each request:
- the check that the session's user still exists
- the admin layout in
app/admin/layout.tsx, which re-reads the role and ban flag getPermissionContextinlib/permissions/check.ts- the ban-expiry branch in
resolveAccountStage(lib/account/stage.ts)
As a result, a ban takes effect on the user's next request instead of when their session ends.
One function to get the session
getSession() in lib/auth/shared.ts wraps getServerSession. Every kind of server code calls it to find out who is signed in:
- oRPC procedures
- zsa server actions
- route middleware
- direct server calls
The header of that file says so directly: no other file should define how auth works.
What middleware does and does not do
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 decide 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 decisions.
Customer records after sign-up
The after hook creates the storefront Customer record in two places:
- the OAuth callback (
/callback/:id), which also callsrevalidatePathon the page it redirects to /sign-up
Both call ensureCustomerExists in server/customers/ensure-customer.ts. This function finds the matching customer or creates and reconciles one, and it is the only place that does so.
Neither of those paths runs on someone's first magic-link sign-in. For that case, getSessionCustomerId in lib/session-customer.ts has a fallback that also calls ensureCustomerExists.
The hook does not send the welcome email. Instead, ensureCustomerExists emits a customer.created event, and functions/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. Because the log happens on the server and not in any client code, 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. Otherwise a logging failure could leave an admin stuck inside someone else's session.
Who can use the admin plugin
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:
adminmanagereditorsupportmarketinganalystcollabuser
This means only the owner can use Better Auth's own endpoints for managing users and sessions.
That is separate from access to the admin. Access to each admin module uses a different system, described in Permissions. Here, Better Auth only controls its own user and session endpoints.
Protecting route handlers
lib/auth/auth-middleware.ts exports two wrappers.
withAuth returns 401 when getSession() finds no user.
withAdminAuth(handler, permission) adds three more checks and returns 403 if any of them fails:
- the fresh permission context has no admin role
resolveAccountStage()reports the account stage asbannedcheckPermissionWithContextrefuses the named permission
The permission argument is required. An API route cannot stop at a broad "is this an admin?" check, because the function signature does not let you write it that way.
Five route handlers use it:
app/api/upload/route.ts
app/api/media/route.ts
app/api/media/associate/route.ts
app/api/ai/models/route.ts
app/api/capabilities/route.tsService keys
lib/auth/shared.ts also handles callers that use a key instead of a session.
validateServiceKey reads a Bearer token from the authorization header. It looks the token up in two places, in this order:
- the scoped keys stored in the database (
validateApiKeyinlib/api-keys/server.ts) - the
SERVICE_API_KEYenv value, as a fallback
A request that matches the env value always gets the same scopes:
read
write:orders
write:webhooksrequireServiceKey throws AuthError with code UNAUTHORIZED instead of returning null. A caller therefore 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.
The browser client
lib/auth/auth-client.ts builds the browser client with adminClient() and magicLinkClient().
It exports these functions:
signInsignOutuseSessionadminlinkSocialunlinkAccountrevokeOtherSessions
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.
lib/auth/types.ts defines AppSession and AppSessionUser. The file imports nothing, so client bundles can use it without pulling in server code.
Related
Permissions
Learn how Litestore resolves an admin role into effective permissions, how grantedPermissions and deniedPermissions override it, and which procedure wraps a server action versus a page.
Keys
Learn how service API keys are minted, hashed and revoked, and what each of the six enforced scopes actually permits.
Learn how Litestore renders, queues and sends email through Resend, how database templates override the React components in emails/, and what the EmailLog ledger records.