BetaMaShop is in public beta. We improve it continuously, and your feedback shapes what comes next.

Wire up authentication

Customizing & extendingEnglish· 7 min read· Updated July 01, 2026

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:

  1. Verifies the token against MaShop's public key (RS256) using Node's crypto, checking exp, issued-at skew, and that the token's project_id claim matches this install.
  2. Calls provisionSsoOwner, which upserts a staff_users row and a staff_roles row keyed on the token's sub (the MaShop user id), and seeds the store locale and currency from the token.
  3. Sets the first-party session cookie mashop_session, valid for 12 hours.
  4. Redirects into the dashboard at /dashboard (or the same-origin next path 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 a staff_users row.
  • role, one of owner, admin, editor, viewer.
  • name, email, avatar_url, shown in the shell header and sidebar.
  • locale and currency, 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.

RoleRankTypical capability
owner4Everything, including owner-only actions.
admin3Manage staff and store settings.
editor2Create and edit catalog, orders, and content.
viewer1Read-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.

VariableRequiredPurpose
SESSION_SECRETYesSigns the session cookie (and dev tokens). Must be at least 16 characters.
MASHOP_SSO_PUBLIC_KEYYes (prod)MaShop's RS256 public key (PEM) that verifies the hand-off token. A single-line PEM with escaped newlines is accepted.
MASHOP_URLRecommendedBase URL of MaShop. Powers the sign-in redirect and the back-channel logout call. No-op when unset.
MASHOP_PROJECT_IDRecommendedThis install's project id. When set, /auth/sso rejects any token whose project_id claim does not match.
ENABLE_DEV_SSONoSet 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-login mints a valid HS256 token (signed with SESSION_SECRET) and bounces it through the real /auth/sso flow, 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 without ENABLE_DEV_SSO=1, the route returns 404 Not found.
  • DISABLE_STAFF_AUTH=1 makes getSession() 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

authsecurityproduction
Was this page helpful?
Your feedback is anonymous.