Wire up authentication
Wire the generated backoffice to MaShop single sign-on, set the four session env vars, and gate routes by staff role from owner down to viewer.
How-to guide.
The generated backoffice has no login form. Staff enter through a signed hand-off from MaShop, and every backoffice route checks a first-party session cookie. This guide shows you how to wire that hand-off, set the env vars it needs, and gate your own routes by staff role.
How the login flow works
There is no email-and-password screen in the dashboard. Authentication is a signed hand-off: MaShop mints a short-lived token, redirects the merchant to /auth/sso?token=…, and the route verifies that token, provisions the staff row, and sets a session cookie.
The /auth/sso route does four things in order:
- Verifies the token against MaShop's public key (
RS256) using Node'scrypto, checkingexp, issued-at skew, and that the token'sproject_idclaim matches this install. - Calls
provisionSsoOwner, which upserts astaff_usersrow and astaff_rolesrow keyed on the token'ssub(the MaShop user id), and seeds the store locale and currency from the token. - Sets the first-party session cookie
mashop_session, valid for 12 hours. - Redirects into the dashboard at
/dashboard(or the same-originnextpath when supplied).
The session token's own expiry is a short handshake window, not the session length. The route issues a fresh 12-hour session (SESSION_TTL_SECONDS = 12 * 60 * 60 in src/lib/auth/session.ts) rather than reusing the token's exp, so the dashboard does not expire a couple of minutes after the hand-off.
A bad, expired, or missing token never lands on a login form. It redirects to /auth/signed-out with a reason query param, a static screen that tells the merchant to reopen the dashboard from MaShop.
Where authentication is enforced
Roles are enforced in application code on every route and server action, not by database row-level security. The generated data-access layer talks to Supabase with the service-role key, so the app checks the session role before it reads or writes. The SQL helper require_staff_role() and the RLS policies exist for the few SECURITY DEFINER RPCs that run under a Supabase-Auth identity; they are not the gate for the SSO session.
The session cookie
The cookie mashop_session is self-contained and stateless. There is no session table. Its format is base64url(JSON payload).base64url(HMAC-SHA256), signed with SESSION_SECRET over HMAC-SHA256 (see encodeSession in src/lib/auth/session.ts).
The cookie is set httpOnly, sameSite: lax, path: /, and secure in production. Its maxAge is derived from the payload exp. The payload carries the connected user and store context:
merchant_user_id, the MaShop user id, which maps to astaff_usersrow.role, one ofowner,admin,editor,viewer.name,email,avatar_url, shown in the shell header and sidebar.localeandcurrency, the UI language and store currency that follow MaShop.exp, Unix-seconds expiry.
Verification is strict. decodeSession compares the signature in constant time, rejects any tampered, malformed, or expired token by returning null, and coerces an unknown role value down to viewer. A null result means no session, which sends the visitor to the sign-out screen.
Staff roles and the role ladder
Four roles form a strict ladder, defined in src/lib/auth/role-utils.ts. Higher ranks include every permission of the ranks below.
| Role | Rank | Typical capability |
|---|---|---|
owner | 4 | Everything, including owner-only actions. |
admin | 3 | Manage staff and store settings. |
editor | 2 | Create and edit catalog, orders, and content. |
viewer | 1 | Read-only access to the backoffice. |
The comparison helper is hasMinRole(actual, min), which returns true when the actual rank is greater than or equal to the required rank. Three named wrappers build on it: canEdit (editor and up), canManageStaff (admin and up), and canOwn (owner only). The Staff settings page, for example, is gated to admin and passes the actor's role into the list so the client can hide controls the actor may not use.
Gate a route or server action
Two functions enforce the session. Use the one that matches where you are.
requireSession, page layouts
requireSession(min) from src/lib/auth/require-session.ts gates a rendered page. When the session is missing or the role is too low, it redirects to MaShop's sign-in with a next param pointing back at the page the visitor tried to open, so they return to the same URL after re-entering:
import { requireSession } from '@/lib/auth/require-session';
export default async function DashboardLayout({ children }) {
const session = await requireSession('viewer');
// session.role, session.email, session.locale, session.currency
return <Shell user={session}>{children}</Shell>;
}
The whole (backoffice) layout already calls requireSession('viewer'), so any page under it inherits the gate. Raise the minimum on a specific page by calling it again with a higher role.
The redirect target is built from MASHOP_URL: ${MASHOP_URL}/login?next=<absolute current URL>. When MASHOP_URL is unset (an early preview without the MaShop side), it falls back to the static /auth/signed-out notice. It never renders a local login form.
requireStaffRole, API routes and server actions
requireStaffRole(min) from src/lib/auth/require-staff-role.ts gates an API route or a server action. It reads the same session cookie, redirects to /auth/signed-out when there is no session, and to /auth/signed-out?reason=insufficient_role when the role is too low. On success it returns { user: { id, email }, role }:
import { requireStaffRole } from '@/lib/auth/require-staff-role';
export async function POST(req: Request) {
await requireStaffRole('editor'); // viewers get bounced
//...perform the write
}
The pattern in the shipped routes is to gate reads at viewer and writes at editor. The products route, for example, calls requireStaffRole('viewer') in its GET and requireStaffRole('editor') in its POST. Both functions default the minimum to viewer when you omit the argument.
Set the environment variables
Set these on your deployment before the SSO hand-off can work. The template ships an .env.example with the same keys.
| Variable | Required | Purpose |
|---|---|---|
SESSION_SECRET | Yes | Signs the session cookie (and dev tokens). Must be at least 16 characters. |
MASHOP_SSO_PUBLIC_KEY | Yes (prod) | MaShop's RS256 public key (PEM) that verifies the hand-off token. A single-line PEM with escaped newlines is accepted. |
MASHOP_URL | Recommended | Base URL of MaShop. Powers the sign-in redirect and the back-channel logout call. No-op when unset. |
MASHOP_PROJECT_ID | Recommended | This install's project id. When set, /auth/sso rejects any token whose project_id claim does not match. |
ENABLE_DEV_SSO | No | Set to 1 only on a throwaway preview to allow the dev test entry on a production build. Leave unset in real production. |
Generate a strong SESSION_SECRET with a long random value:
openssl rand -base64 48
The one error you will hit
If SESSION_SECRET is unset or shorter than 16 characters, the session code throws SESSION_SECRET is not set (need ≥16 chars). It signs the dashboard session cookie. Set it first, because both signing and verifying the cookie depend on it.
Test the shell before MaShop is wired
Two dev-only affordances let you preview the dashboard without a live MaShop hand-off. Both are gated to non-production, or to a production build that explicitly sets ENABLE_DEV_SSO=1.
/auth/dev-loginmints a valid HS256 token (signed withSESSION_SECRET) and bounces it through the real/auth/ssoflow, so you exercise the true 12-hour session path. Pass?role=owner,?email=,?name=,?locale=,?currency=, or?next=to shape the token. On a real production build withoutENABLE_DEV_SSO=1, the route returns404 Not found.DISABLE_STAFF_AUTH=1makesgetSession()return a synthetic owner when no real cookie is present, so the shell renders without any hand-off. It is honoured only when dev auth is allowed.
Neither path is reachable on a real production deploy unless you opt in, so the RS256 hand-off cannot be downgraded to a dev token in production.
Log out
The logout server action in src/lib/auth/logout.ts runs a cascade: a best-effort back-channel POST to ${MASHOP_URL}/api/sso/logout so the MaShop session dies too, then it clears the local cookie, then it redirects to /auth/signed-out?reason=logged_out. The back-channel call has a 2.5-second timeout and never blocks logout: if MaShop is slow or unreachable, the local cookie is still cleared and the user is signed out on this side. When MASHOP_URL is unset, the back-channel step is skipped.
Manage staff
The Staff page under settings is gated to admin. From there an admin invites a collaborator by email, which provisions the account and creates the staff_users and staff_roles rows, and updates or removes existing members. Owner is the top rank and is granted only by another owner. See Collaborators for how roles map to what each person can do.
Related
- Connect your Supabase project for the database keys the session and provisioning code read.
- Row-level security for the
staff_roles-based policies and therequire_staff_role()RPC. - Customize the API for the route conventions the role gates plug into.
