Authentication & Tenancy

How auth works across the stack

MVS Pay has three distinct authentication layers, each guarding a different boundary. Understanding which layer applies where is the single most important thing to internalize before touching any request path — they use different credentials, fail in different ways, and carry different blast radius.

LayerBoundaryCredentialWhere it lives
1. End-user authBrowser → MVS Pay UI (Next.js)Better Auth session cookie / local JWTapps/mvs-pay/lib/auth-server.ts, apps/mvs-pay/proxy.ts, apps/mvs-pay/app/api/auth/callback/route.ts
2. API-key authExternal integration → MVS Pay UI API routesmvspay_{env}_… key in x-api-keyapps/mvs-pay/lib/api-keys.ts, apps/mvs-pay/lib/api-auth.ts
3. Service authMVS Pay UI (and other repos) → extensions serviceShared MVS_PAY_EXTENSIONS_INTERNAL_SECRET + x-org-idapps/mvs-pay-extensions/src/middleware/auth.ts, packages/mvs-pay-sdk/src/http.ts

The extensions service is never reached directly by a browser. The only thing that holds MVS_PAY_EXTENSIONS_INTERNAL_SECRET is a server-side caller (the UI’s Route Handlers, HealthOS, mvs-c2). See Extensions Service.


Layer 1 — End-user auth (Better Auth + cross-domain)

End users authenticate against the central auth service (auth.mvscloud.com, built on Better Auth). MVS Pay itself does not own a login form or a user table — it is a consumer of central auth via the @mvs/auth package. There are two valid session shapes the UI accepts, and the distinction is entirely about which domain the browser is on.

Two session shapes

getSession() in apps/mvs-pay/lib/auth-server.ts tries them in order:

1

Local mvspay.io JWT (cross-domain)

A short-lived HS256 JWT minted by MVS Pay itself, stored in a session cookie. Used when the browser is on the external mvspay.io domain (which cannot share the .mvscloud.com central-auth cookie). Verified locally with jwtVerify against getMvsPaySessionSecret() — no network call.

2

Central auth session (same-domain)

The standard Better Auth session cookie on .mvscloud.com. Used when the browser is on pay.mvscloud.com / pay-dev / pay-staging. Validated by calling centralAuth.api.getSession({ headers }).

getSession() is wrapped in React cache(), so it resolves at most once per request. A failure of the central lookup is logged and treated as unauthenticated rather than throwing — see the try/catch around centralAuth.api.getSession.

The local JWT carries org context inline (orgId, orgSlug, orgName) so the UI can render shell chrome without a control-plane round-trip. It is not trusted for authorization — see Re-validation.

The cross-domain token-exchange flow

Because mvspay.io is a separate registrable domain from mvscloud.com, the central auth cookie is not visible there. The flow bridges that gap with a one-time token exchange.

Where each piece lives:

  • apps/mvs-pay/proxy.ts (Next.js middleware) decides redirect targets. For external domains it sends the browser to the central /api/cross-domain-token URL (TOKEN_URLS); for .mvscloud.com it uses the plain /login URL (LOGIN_URLS). isExternalDomain() returns true for any host that is not *.mvscloud.com, mvscloud.com, or localhost.
  • apps/mvs-pay/app/api/auth/callback/route.ts exchanges the one-time token for { user, organization } via POST {centralAuth}/api/exchange-token, then mints the local session JWT with jose SignJWT (alg HS256, 7-day expiry, sub = user.id) and sets it as an httpOnly cookie.

Dev caveat (from proxy.ts): the TOKEN_URLS.dev and LOGIN_URLS.dev entries both point at mvscloud.com (auth-dev / pay-dev), not an mvsdev.io equivalent. The comment in the file is explicit: “Dev uses the same mvscloud.com URLs since mvsdev.io is only for local ngrok.” Only prod TOKEN_URLS redirects to mvspay.io. So the cross-domain JWT path is primarily exercised in production; dev/staging mostly use the same-domain cookie.

The cookie name is environment-dependent (apps/mvs-pay/app/api/auth/callback/route.ts) and the search set is sourced from @mvs/config (mvsPay.sessionCookieNames, read in both proxy.ts and auth-server.ts):

ContextCookie name
Production (mvspay.io)__Secure-mvspay.session_token (domain .mvspay.io)
Production (mvscloud)__Secure-healthos.session_token (central)
Developmentmvspay.session_token / healthos.session_token

The session secret is resolved by apps/mvs-pay/lib/session-secret.ts: MVSPAY_SESSION_SECRET (falling back to BETTER_AUTH_SECRET), read from env first, then Infisical. It is memoized in a module-level promise.

What the proxy actually gates

proxy.ts is coarse-grained presence-only auth. It checks only whether a session cookie exists — it does not verify the JWT signature or the central session. Its job is to bounce un-authed users on protected routes to login. Real verification happens server-side in getSession().

1const protectedPaths = [
2 "/overview", "/transactions", "/settlements", "/disputes",
3 "/customers", "/devices", "/settings", "/onboarding",
4];

The proxy also handles tenant subdomains for the public payment portal: a request to acme.mvspay.io/<path> (anything that is not a root domain) is internally rewritten to /_tenants/acme/<path> while the browser still shows acme.mvspay.io. That portal path is public and does not go through the protected-route check.

Re-validation of org claims

The org claim inside the local JWT is never trusted on its own for scoping payment operations. getMvsPaySession() in auth-server.ts re-validates it against current control-plane membership:

  • For a local-JWT session, it calls resolveCentralMembershipForMvsPay({ userId, activeOrganizationId: localOrg.id, allowSingleMembershipFallback: false }). If the user is no longer a member of that org, the session resolves to nullrevoked users cannot retain stale merchant scope (per the code comment).
  • For a central session it reads session.activeOrganizationId and allows the single-membership fallback (if the user belongs to exactly one org, use it).
  • A user belonging to more than one org with no active org selected resolves to null — the code explicitly refuses to pick an arbitrary org.

getMvsPaySession() then enriches with the PlatformOrganization (looked up by slug) and computes onboarding status. The resolved finixMerchantId is either the legacy platformOrg.finixMerchantId or, post-pivot, the hyperswitchMerchantId of the org’s active MerchantExtension — the contract field name is kept as finixMerchantId for backward compatibility (see The Hyperswitch Pivot).

In page/server-component code, call requireAuth() — it returns the MvsPaySession or throws "UNAUTHORIZED" (caught upstream to redirect to login). In Route Handlers, use the withAuth wrapper (Layer 2) instead.


Layer 2 — API-key auth (external integrations)

External callers (e.g. HealthOS) authenticate to the UI’s Route Handlers with an API key in the x-api-key header. This is implemented in apps/mvs-pay/lib/api-keys.ts and apps/mvs-pay/lib/api-auth.ts. See the API Reference for the per-endpoint contracts.

Key format & storage

Keys have the shape mvspay_{env}_{64 hex chars}, where {env} is sandbox or live — e.g. mvspay_live_abc123…. Generation (generateApiKey):

  • random part = randomBytes(32).toString("hex") (32 bytes → 64 hex chars);
  • the full key is never stored — only its sha256 hash (hashApiKey) and a display keyPrefix (mvspay_{env}_ + first 12 hex chars);
  • the plaintext key is returned once at creation time and never again.

Records live in the control-plane mvsPayApiKey table (Prisma), scoped to an orgId, with scopes, environment, optional expiresAt, isActive, usage counters, and provenance fields (sourceApp, sourceOrgId, createdByUserId).

Validation

validateApiKey() rejects (returns null) when the key: does not start with mvspay, hash not found, isActive === false, or expiresAt is in the past. On success it bumps lastUsedAt / totalRequests non-blocking (the update promise is fire-and-forget with .catch(() => {})). It returns the org id/slug, scopes, environment, and the org’s finixMerchantId.

Scopes

Scopes are resource:action strings. hasScope(scopes, required) matches in three ways: exact match, a resource wildcard (transactions:* grants transactions:read), or the full wildcard *.

ScopeMeaning
transactions:read / transactions:writeView / create transactions, refunds
devices:read / devices:writeView / manage terminal devices, push sales
customers:read / customers:writeView / create customers & payment methods
payment_links:read / payment_links:writeView / create payment links
settlements:read / settlements:writeView / manage settlement queue & instant payouts
disputes:read / disputes:writeView / manage disputes & evidence
merchants:read / merchants:writeView / manage merchant account config
subscriptions:read / subscriptions:writeView / manage plans & subscriptions
webhooks:read / webhooks:writeView / manage webhook endpoints
*Full access (all permissions)

Default scope set is very broad. generateApiKey() defaults to every read+write scope except the * wildcard when scopes is omitted. The default environment is sandbox. Always pass an explicit, minimal scopes array when minting keys for an integration. Note also disputes:write is in the default-grant list but is not present in the API_SCOPES definition map — the list of mintable defaults and the documented scope catalog have drifted. Flag this when auditing keys.

How a request is authenticated

authenticateRequest(request, options) (api-auth.ts) is the single entry point. Priority:

1

x-api-key present → API-key path

Validate the key, then optionally enforce options.requiredScope (403 on miss) and options.requireMerchant (400 if no finixMerchantId).

2

No x-api-key → session path

Fall back to getMvsPaySession() (Layer 1). 401 if unauthenticated, 404 if no platformOrg, 400 if requireMerchant and no merchant.

Both paths converge on a single ApiContext so downstream handler code does not care which method was used:

1interface ApiContext {
2 authMethod: "session" | "api_key";
3 orgId: string;
4 orgSlug: string;
5 organizationId: string; // alias of orgId — used by query helpers
6 finixMerchantId: string | null;
7 environment: string;
8 session?: MvsPaySession; // only when authMethod === "session"
9 apiKey?: ApiKeyInfo; // only when authMethod === "api_key"
10}

Wrap handlers with withAuth(handler, options) to get auth + the apiError envelope for free:

1export const POST = withAuth(
2 async (req, ctx) => { /* ctx.organizationId is your tenant key */ },
3 { requiredScope: "transactions:write", requireMerchant: true },
4);

Layer 3 — Service auth (UI → extensions)

The UI’s Route Handlers do not talk to Hyperswitch directly; they call the extensions service (mvs-pay-extensions, a Hono app) through the @mvs/mvs-pay-sdk client. That boundary is guarded by authMiddleware() in apps/mvs-pay-extensions/src/middleware/auth.ts.

This layer is governed by ADR-010 — read it before changing anything here.

What the middleware enforces

Every request to the extensions service must present:

HeaderRequiredPurpose
x-internal-secretyesService-to-service trust. Compared to config.internalSecret (MVS_PAY_EXTENSIONS_INTERNAL_SECRET) with a constant-time comparison (timingSafeEqual).
x-org-id or x-org-slugone requiredTenant context. At least one must be present (400 TENANT_REQUIRED otherwise).
x-request-idnoCorrelation id; generated if absent.

Behavior:

  • If the expected secret is unset, the service refuses all traffic with 503 GATEWAY_NOT_CONFIGURED (fail-closed — there is no “no auth” mode).
  • A missing/wrong secret → 401 UNAUTHORIZED (“Invalid internal secret”).
  • On success the middleware attaches tenant = { organizationId, organizationSlug } and requestId to the Hono context for downstream handlers.

The caller side is packages/mvs-pay-sdk/src/http.ts + the UI factory apps/mvs-pay/lib/mvs-pay-client.ts. The SDK injects x-internal-secret, x-org-id / x-org-slug, and (when present) idempotency-key + x-request-id. mvsPayClientFromAuth(ctx) builds a per-request, org-scoped client straight from the Layer-2 ApiContext, so the tenant from getMvsPaySession() /API key flows straight through to the extensions service. The SDK constructor requires either orgId or orgSlug — you cannot accidentally make an un-scoped tenant call (except the deliberate mvsPaySystemClient() for system-level routes).

The deferred per-user JWT gap (ADR-010)

This is the most important honesty point on the page. ADR-010 (Accepted 2026-05-28) decided to ship with only the shared secret and defer per-user identity propagation until the mvs-auth plan lands.

The extensions service has no notion of which user made a request. The shared MVS_PAY_EXTENSIONS_INTERNAL_SECRET proves a trusted service is calling, with a tenant id — nothing more. Per-user identity is intentionally not propagated across this boundary yet.

Concrete consequences in the current code:

  • phiAudit.logAccess() records no user. In apps/mvs-pay-extensions/src/routes/payments.ts the PHI audit call passes userId: undefined, with the comment “userId is null until mvs-auth lands per ADR-010.” So PHI access trails attribute to an organization, not an individual user. This is a known compliance gap, not a bug to “fix” casually.
  • Blast radius. ADR-010 names the risk plainly: “an upstream compromise has full payments blast radius.” The shared secret, if leaked, authorizes calls for any tenant (the caller simply sets x-org-id). Mitigations relied on: rate-limit middleware (Phase 3), an external WAF, and quarterly secret rotation — see Secrets Rotation and Vercel WAF.
  • Migration path. When mvs-auth lands, every route adds JWT validation alongside x-internal-secret; the shared secret is decommissioned over a 90-day overlap window once all consumers (mvs-pay UI, HealthOS, mvs-c2) cut over.

Tenant isolation

There is no row-level security and no per-tenant database. The dedicated mvs-pay database is a single shared schema (see Data Model). Isolation is enforced in application code by scoping every query with the tenant’s organizationId.

The chain of custody for the tenant id:

  1. Layer 1/2 resolve it into ApiContext.organizationId (from the session’s platformOrg.id or the API key’s orgId).
  2. Layer 3 carries it across the wire as x-org-id and the middleware lands it as c.get("tenant").organizationId.
  3. Extensions handlers must put it in every Prisma where. Examples in apps/mvs-pay-extensions/src/routes/plaid.ts:
1const tenant = c.get("tenant");
2// every read/write is scoped to the caller's org
3const row = await db.plaidItem.findFirst({
4 where: { organizationId: tenant.organizationId, /* ... */ },
5});
6// even encryption keys are derived per-org
7const enc = encryptForOrg(tenant.organizationId, accessToken);

Compound unique indexes such as (organizationId, itemId) (see routes/webhooks-plaid.ts) bound lookups to a single tenant at the schema level.

Because isolation is purely a code convention, a query that forgets where: { organizationId } is a cross-tenant data leak, not a 500. This is the highest-severity class of bug in the extensions service. Treat any new query without an organizationId (or an (organizationId, …) compound key) as a blocker in review.


Quick reference: which layer am I in?

You are writing…LayerUse
A protected UI page / server component1requireAuth()MvsPaySession
A UI Route Handler (session or API-key callers)2withAuth(handler, { requiredScope, requireMerchant })
A call from a Route Handler to the extensions service3mvsPayClientFromAuth(ctx) (SDK injects secret + x-org-id)
A route inside the extensions service3Trust authMiddleware(); read c.get("tenant"); scope every query by organizationId

See also: Architecture, Merchant & Operator UI, Secrets Management.