Authentication
Porulle uses Better Auth for authentication. This guide covers configuring roles, permissions, API keys, social login, and multi-store resolution in defineConfig. For the underlying design rationale, see Identity and Store Resolution.
Roles and permissions
Section titled “Roles and permissions”Define roles in auth.roles. Each role has an array of permission strings in "module:action" or "module:action:scope" format.
import { defineConfig } from "@porulle/core";
export default defineConfig({ auth: { roles: { owner: { permissions: ["*:*"] }, admin: { permissions: ["*:*"] }, staff: { permissions: [ "catalog:create", "catalog:update", "catalog:read", "catalog:read:unpublished", "inventory:adjust", "inventory:read", "orders:create", "orders:create:on-behalf", "orders:read", "orders:update", "customers:read", "cart:create", "cart:update", ], }, customer: { permissions: [ "catalog:read", "cart:create", "cart:read", "cart:update", "orders:create", "orders:read:own", "customers:read:self", "customers:update:self", ], }, }, },});Available permission scopes:
| Module | Actions | Scoped variants |
|--------|---------|-----------------|
| catalog | create, read, update, delete | read:unpublished |
| orders | create, read, update | create:on-behalf, read:own |
| customers | read, update | read:self, update:self |
| cart | create, read, update | — |
| inventory | read, adjust | — |
| media | read, write | — |
| pricing | read, manage | — |
| promotions | read, manage | — |
| webhooks | manage | — |
| audit | read | — |
| jobs | admin | — |
| * | * | Wildcard — grants everything |
Plugins declare additional scopes (e.g., loyalty:read, pos:operate, gift-cards:admin). Check the plugin’s manifest for its permission names.
Trusted origins
Section titled “Trusted origins”Better Auth requires trustedOrigins for CSRF protection. Browsers send session cookies with cross-origin requests — without an explicit allowlist, the server rejects them.
auth: { trustedOrigins: [ "http://localhost:4000", "https://store.example.com", "https://admin.example.com", ],}Add every origin that will make authenticated requests.
Resolving a session into an Actor
Section titled “Resolving a session into an Actor”Better Auth already reads the session cookie or bearer token from request headers. For an in-process server function, use the Better Auth instance directly, then use Porulle’s resolver when the commerce API needs the organization and permission context:
import { resolveActor } from "@porulle/core";import { commerce } from "./commerce.js";import config from "./commerce.config.js";
export async function getCurrentActor(request: Request) { const session = await commerce.auth.api.getSession({ headers: request.headers, }); if (!session) return null;
return resolveActor(request.headers, commerce.auth, config);}In a custom Hono route registered with config.routes, the same auth instance is available as c.get("auth"):
app.get("/api/current-user", async (c) => { const actor = await resolveActor(c.req.raw.headers, c.get("auth"), config); return c.json({ actor });});For server-side rendering, forward the incoming request headers to the same in-process instance. Do not fetch the session endpoint yourself or parse set-cookie:
export async function loader({ request }: { request: Request }) { const actor = await resolveActor(request.headers, commerce.auth, config); if (!actor) return new Response("Signed out", { status: 401 });
const products = await commerce.withActor(actor).catalog.list({ limit: 20 }); return Response.json({ products });}When the frontend is a separate service, use Better Auth’s client directly so it manages the session cookie:
import { createAuthClient } from "better-auth/client";
export const authClient = createAuthClient({ baseURL: "https://api.example.com",});The session cookie prefix and name are exported as AUTH_COOKIE_PREFIX and SESSION_COOKIE_NAME ("uc.session_token") for consumers that explicitly set or clear cookies.
null means anonymous — it does not mean “something went wrong”
Section titled “null means anonymous — it does not mean “something went wrong””Since 0.16.0 resolveActor distinguishes the two:
- Returns
nullwhen the session was evaluated and there is no valid one. Anonymous. Handle it. - Throws when the check could not run at all — the session store is unreachable, the auth tables have drifted from what Better Auth expects, two copies of Better Auth are resolved into one module graph.
Before 0.16.0 both returned null, so a fault presented as a signed-out user. A caller told “signed out” goes and inspects their session; when the session was never read, that sends them somewhere the fault is not.
So do not wrap it in a catch that swallows:
// Wrong — an outage now looks exactly like a signed-out visitorlet actor = null;try { actor = await resolveActor(request.headers, commerce.auth, config);} catch { actor = null;}Let it throw. Inside createServer the middleware already logs the fault with its stage, path, and method, and returns 500. In your own loader or server function, treat a throw as a 500 rather than a redirect to sign-in.
API keys
Section titled “API keys”auth: { apiKeys: { enabled: true },}Clients authenticate by sending the key in the x-api-key header:
curl -H "x-api-key: your-key" http://localhost:4000/api/catalog/entitiesThere is no built-in development key. auth.enableDevKey and auth.devKey were removed, and createServer now throws on boot if either is set — a shared hardcoded credential is indistinguishable from a real one in an audit log, and it invariably reaches production.
Every key is minted against a named scope you declare in auth.apiKeyScopes:
auth: { apiKeys: { enabled: true }, apiKeyScopes: { storefront: { prefix: "sf_", description: "Public storefront reads", permissions: { catalog: ["read"] }, }, },}bunx @porulle/cli api-key create --scope storefront--scope is required and must name a scope in your config; run api-key list to see what is declared. Other flags: --name (defaults to <scope>-key), --config, --ttl (seconds, minimum 86400), --user, --save-env, --env-var. The key is shown once.
Social login
Section titled “Social login”auth: { socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }, github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }, },}Two-factor authentication
Section titled “Two-factor authentication”auth: { twoFactor: { enabled: true, requiredForRoles: ["owner", "admin"], },}If you omit requiredForRoles, 2FA is optional for all users.
Bearer token authentication
Section titled “Bearer token authentication”After sign-in, use the session token as a Bearer token for non-browser clients:
TOKEN=$(curl -s -X POST http://localhost:4000/api/auth/sign-in/email \ -H "content-type: application/json" \ -d '{"email":"admin@example.com","password":"secret"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
curl -H "Authorization: Bearer $TOKEN" http://localhost:4000/api/catalog/entitiesReact Native / Expo
Section titled “React Native / Expo”npx expo install @better-auth/expo expo-secure-storeimport { createAuthClient } from "better-auth/react";import { expoClient } from "@better-auth/expo/client";import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({ baseURL: "https://api.example.com", plugins: [ expoClient({ scheme: "myapp", storagePrefix: "myapp", storage: SecureStore, }), ],});The expoClient plugin handles token storage, refresh, and header injection automatically.
Multi-store resolution
Section titled “Multi-store resolution”For multi-tenant SaaS deployments, configure storeResolver to map requests to organization IDs:
auth: { strictOrgResolution: true, storeResolver: async (request) => { const storeId = request.headers.get("x-store-id"); return storeId ?? null; },}If strictOrgResolution is true and resolution returns null, the request is rejected with HTTP 503. Single-store deployments do not need storeResolver.
Session options
Section titled “Session options”auth: { requireEmailVerification: true, sessionDuration: 60 * 60 * 24 * 7, // 7 days in seconds}Authentication methods summary
Section titled “Authentication methods summary”| Method | Header | Best for |
|--------|--------|----------|
| Session cookie | Auto-set by browser | Web storefronts, admin panels |
| Bearer token | Authorization: Bearer <token> | Mobile apps, SPAs, server-to-server |
| API key | x-api-key: <key> | External integrations, CI, AI agents |
Security model
Section titled “Security model”The adopter security contract is in the Security Model. It documents rate limits, cookie hygiene (__Secure- prefix, HttpOnly, SameSite: lax), the two org resolution profiles (B2C single-storefront, B2B multi-tenant), and Phase 2 gaps (agent attestation, per-region data residency).