Litestore · Developer docs
Authentication

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.

Inbox
Sign in to Your StoreTo ava@example.com
now
YYour sign-in linkClick the button below to sign in. No password needed.Sign inDidn't ask for this? You can ignore this email.
The shopper gets an email with a link, and clicking it signs them in.

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

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:

PathPresetLimit
/sign-in/magic-linkrateLimits.authMagicLink3 per minute
Sign-in links requested this minute5 / limit 3
Limit
SentSentSentBlockedBlocked
Someone asks for five sign-in links in one minute, and Litestore blocks every request after the third.

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:

platform/auth/server.ts
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:

CallerWhereWhy it reads the user
Session checkgetSessionConfirms the session's user still exists.
Admin layoutapp/admin/layout.tsxRe-reads the role and ban flag.
getPermissionContextplatform/permissions/check.tsBuilds the user's permissions.
resolveAccountStagemodules/customers/stage.tsHandles 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:

platform/auth/server.ts
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:

platform/auth/server.ts
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.ts

Service 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.

server.ts
client.ts
service-keys.ts
with-admin-auth.ts
errors.ts
session-types.ts

On this page