Litestore · Developer guide

Overview

Learn how Litestore signs users in with Better Auth magic links, when Google OAuth registers, how sessions are re-validated, and what the impersonation hooks write to the activity log.

Litestore authenticates with Better Auth on a Prisma adapter, mounted at app/api/auth/[...all]/route.ts.

It registers exactly two plugins: magicLink and admin. There is no third, and the small plugin list is the reason the sign-in surface is as narrow as it is.

A shopper signs in by receiving an email link, or with Google when Google credentials are configured.

This page covers the sign-in routes that exist, the session re-validation that makes a ban take effect immediately, and the audit entries the impersonation hooks write.

The two ways in

lib/auth.ts configures the magicLink plugin with a sendMagicLink callback that renders emails/magic-link-simple and hands it to sendEmail:

lib/auth.ts
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 delivers through Resend.

env.ts declares RESEND_API_KEY and RESEND_SENDER_EMAIL as required strings — not optional.

That is why Resend is a hard requirement rather than an integration. Without it the magic link is never delivered, and magic link is the only sign-in route that is always available.

Google

Google is the second route, and it registers conditionally:

lib/auth.ts
// 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 requires both, so setting only one leaves socialProviders empty — a half-configured Google is the same as no Google.

account.accountLinking is enabled, so a Google sign-in on an existing email links to that account instead of creating a second one.

After the fact, components/web/dashboard/settings/account-security.tsx exposes linkSocial and unlinkAccount for the provider.

There is no email-and-password flow

lib/auth.ts never sets emailAndPassword, so Better Auth leaves it disabled. The comment above socialProviders says a self-hoster "can run email/password only" — the configuration does not do that.

A leftover of the same assumption sits in the before hook, which still rate-limits four endpoints that are not enabled:

/sign-in/email
/sign-up/email
/forget-password
/reset-password

Those branches guard nothing. They are dead paths kept alive by a limiter.

The sign-in UI

The UI matches the two routes that exist. components/web/auth/login.tsx renders:

  1. LoginForm — an email field calling signIn.magicLink
  2. LoginButton — a button calling signIn.social({ provider: "google" })

On success the form pushes to getVerifyUrl(email), which is /auth/verify?email=….

Rate limits on the auth endpoints

The before hook is a createAuthMiddleware that keys off getIP() and the Better Auth path.

The limiters are defined in lib/rate-limit/core.ts:

PathPresetLimit
/sign-in/email, /sign-in/credentials, /sign-up/emailrateLimits.authLogin5 per minute
/sign-in/magic-linkrateLimits.authMagicLink3 per minute
/forget-password, /reset-passwordrateLimits.authPasswordReset3 per hour

A blocked request is answered with a plain Response.json carrying a Retry-After header.

That is not Better Auth's own error envelope, so the client receives a 429 with message undefined. A component that simply rendered the error field would show the user nothing.

authErrorMessage in lib/auth/auth-errors.ts is the single place that turns that into readable text, and it is the only branch that explains a blocked send. Every auth onError in the login components routes through it.

checkRateLimit fails open when Redis is unreachable, so an outage does not lock users out of sign-in.

Sessions and the fresh principal read

Sessions last 7 days, refresh after 24 hours, and are cookie-cached for the full 7 days.

A cookie cache that long has a direct consequence: the role and ban flag carried in the session can lag the database by days.

The design accepts that lag and then refuses to depend on it. Nothing authoritative reads the session's copy of the role or the ban flag.

getServerSession re-checks that the user row still exists on every request. getFreshUserById is the one cache()-coalesced read of the principal:

lib/auth.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 is what makes that affordable. Four callers share the single query per request:

  • The session existence check
  • The admin layout's fresh role and ban re-read in app/admin/layout.tsx
  • getPermissionContext in lib/permissions/check.ts
  • The ban-expiry branch in resolveAccountStage (lib/account/stage.ts)

That is why a ban takes effect on the next request rather than at the end of a session.

One canonical call

getSession() in lib/auth/shared.ts wraps getServerSession and is the canonical call for every transport:

  • oRPC procedures
  • zsa server actions
  • route middleware
  • direct server calls

That file's header says so explicitly: auth semantics are not to be defined anywhere else.

What middleware does and does not do

middleware.ts only checks for the presence of a session cookie via getSessionCookie.

It redirects unauthenticated requests for /admin and /dashboard to /auth/login?next=…, and bounces signed-in users away from /auth.

It performs no authorization. A cookie's presence is not a claim about who you are — that happens in the admin layout, the page guard, and the permission procedures.

Customer records after sign-up

The after hook creates the storefront Customer on two paths:

  1. The OAuth callback (/callback/:id, which also calls revalidatePath on the redirect location)
  2. /sign-up

Both call ensureCustomerExists, the canonical find-or-reconcile in server/customers/ensure-customer.ts.

Neither path fires on a first magic-link sign-in. That case is covered by the fallback in getSessionCustomerId (lib/session-customer.ts), which routes through the same ensureCustomerExists.

The welcome email is not sent from the hook at all. ensureCustomerExists emits customer.created and functions/domain-events.ts handles it.

Moving the email behind the event is what stopped the duplicate welcome on sign-up and the missing one on OAuth. One reconcile function emits one event, whichever path arrived at it.

Impersonation audit

An admin signing in as another user is logged from the after hook rather than from any client path, so nothing can bypass it.

Better Auth stamps session.impersonatedBy on the impersonated session. The hook reads it and writes a user_impersonated activity entry:

lib/auth.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(() => {})
  }
}

/admin/stop-impersonating writes the matching entry with action: "impersonate_stop" and the restored admin's own id.

Both calls end in .catch(() => {}). A failed audit write does not break the session transition — the audit is best-effort, and the alternative would be a logging failure that locks an admin into an impersonated session.

Who can use the admin plugin

The admin plugin is registered with adminRoles: ["owner"] and allowImpersonatingAdmins: false.

Its role map grants one role real capabilities and empties the rest:

lib/auth.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:

  • admin
  • manager
  • editor
  • support
  • marketing
  • analyst
  • collab
  • user

Better Auth's own user- and session-management endpoints are therefore owner-only.

That is not the same thing as admin access. Module-level admin access is a separate system described in Permissions — Better Auth governs only its own user and session endpoints here.

Guarding route handlers

lib/auth/auth-middleware.ts exports two wrappers.

withAuth returns 401 when getSession() yields no user.

withAdminAuth(handler, permission) layers three checks on top and returns 403 on any of them:

  • No admin role in the fresh permission context
  • An account stage of banned from resolveAccountStage()
  • A failed checkPermissionWithContext for the named permission

It takes the permission as a required argument specifically so an API route cannot stop at a broad "is admin" check. The signature makes the lazy version unwriteable.

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

Service keys

lib/auth/shared.ts also carries the non-session credential path.

validateServiceKey reads a Bearer token from the authorization header. It resolves in two stages:

  1. The database-backed scoped keys (validateApiKey in lib/api-keys/server.ts)
  2. A fallback to the SERVICE_API_KEY env value

The env fallback resolves to a fixed set of scopes:

read
write:orders
write:webhooks

requireServiceKey throws AuthError with code UNAUTHORIZED instead of returning null, so a caller cannot accidentally continue on a null.

hasScope(scopes, required) is the scope test. AuthError extends AppError, and its codes are UNAUTHORIZED or FORBIDDEN.

The client

lib/auth/auth-client.ts builds the browser client with adminClient() and magicLinkClient().

It exports:

  • signIn
  • signOut
  • useSession
  • admin
  • linkSocial
  • unlinkAccount
  • revokeOtherSessions

Its baseURL is window.location.origin in the browser and env.NEXT_PUBLIC_SITE_URL on the server.

That split exists because of a real failure: hardcoding the public URL previously sent every localhost dev session fetch to production.

lib/auth/types.ts holds AppSession and AppSessionUser. It is kept dependency-free so it can cross into client bundles without dragging server code with it.

On this page