Ecommerce Boilerplate
Configuration

Authentication

Who the visitor is — Firebase Auth by default, Auth.js with sessions in your own database.

Identity is a separate choice from where your data lives, and the app is deliberately incurious about it. Server-side, the whole contract is one function:

src/lib/auth/types.ts
export interface AuthUser {
  uid: string;
  email: string | null;
}

uid is what every commerce record is keyed by — carts, quotes, orders, customer profiles, and the admins table. Nothing else about the visitor reaches the commerce layer.

Choosing a provider

AUTH_PROVIDERCredentialSessions live inNeeds a Firebase project
unset / firebaseFirebase ID token, Authorization: BearerFirebase (stateless token)Yes
authjsAuth.js session cookie (httpOnly)Your SQL databaseNo

The browser side reads NEXT_PUBLIC_AUTH_PROVIDER and consumes exactly two fields of the signed-in visitor — uid and email — through useUser(). No component imports a provider SDK type any more.

authjs requires a SQL backend. The shipped firestore.rules gate on request.auth, so a browser holding an Auth.js cookie is a stranger to Firestore and every client-side read is denied. Firestore-as-data stays paired with Firebase Auth; a free choice of identity is a SQLite / Postgres / Supabase feature.

Auth.js (no Firebase)

.env.local
AUTH_PROVIDER=authjs
NEXT_PUBLIC_AUTH_PROVIDER=authjs   # the browser needs to know too — see below
AUTH_SECRET=                       # openssl rand -base64 32
COMMERCE_BACKEND=sqlite            # or postgres / supabase
DATABASE_URL="file:./dev.db"

Both variables, like the commerce backend's pair. The server one picks who verifies; the NEXT_PUBLIC_ one tells the browser to stop attaching a Firebase ID token to /api/commerce calls. Set only the server one and every signed-in request arrives with a Bearer token nothing reads — harmless, but the session cookie is doing all the work.

Then at least one sign-in method. Magic links reuse the Resend key the quote emails already use; Google needs its own OAuth client with the callback set to https://your-store.example.com/api/auth/callback/google:

.env.local
RESEND_API_KEY=          # magic links
AUTH_GOOGLE_ID=          # and/or Google
AUTH_GOOGLE_SECRET=

A provider with no credentials is simply not offered, so no button appears that cannot work. Push the schema and the session tables come with it:

Terminal
COMMERCE_BACKEND=sqlite npm run prisma:push

What that gives you

Sessions are rows, not tokens (strategy: "database"). A row can be deleted, so signing out and revoking access actually take effect; a stateless JWT stays valid until it expires no matter what you do.

Sign-in uses the storefront's own /login screen — the same one Firebase uses. pages.signIn points at it because the form now goes through getAuthClient() rather than calling a provider SDK, so one page drives whichever provider is configured.

No component imports a provider's auth SDK any more. useUser() returns { currentUser, isLoading }, and the loading flag matters: every provider answers asynchronously, so a guard that treats "not known yet" as "signed out" bounces signed-in visitors to the login page on their first paint.

Granting admin

Unchanged, and still not something a client can do: row existence in admins, keyed by the uid — which is now the Auth.js User.id.

Terminal
sqlite3 dev.db "insert or ignore into admins (uid) values ('<user id>');"

How the seam works

getAuthUser(request) resolves the provider named by AUTH_PROVIDER, loading it lazily so choosing one never pulls in the other's dependencies:

src/lib/auth/index.ts
export async function getAuthUser(request: Request): Promise<AuthUser | null> {
  try {
    const provider = await getAuthProvider();
    return await provider.authenticate(request);
  } catch (error) {
    console.error("[auth] could not identify the caller:", error);
    return null;
  }
}

Each provider knows where its own credential hides — a Bearer header for Firebase, a cookie for Auth.js — and the commerce RPC route knows neither.

It fails closed, deliberately unlike the rate limiter. A limiter that blocks checkout during its own outage is worse than the abuse it prevents; an identity provider that errored into "sure, you're someone" would be an authorization bypass. A throwing provider reads as unauthenticated and logs [auth].

Self-hosting note

The Auth.js config sets trustHost: true. Without it, Auth.js refuses to build callback URLs from the Host header and every self-hosted deployment answers 500 UntrustedHost on its first request. Trusting the header is right behind a proxy you control — the deployment this boilerplate targets — and Vercel sets the equivalent automatically. If your app is reachable on a host you do not own, pin AUTH_URL instead.

On this page