Authentication & Tenancy
Authentication & Tenancy
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.
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:
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.
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-tokenURL (TOKEN_URLS); for.mvscloud.comit uses the plain/loginURL (LOGIN_URLS).isExternalDomain()returnstruefor any host that is not*.mvscloud.com,mvscloud.com, orlocalhost.apps/mvs-pay/app/api/auth/callback/route.tsexchanges the one-timetokenfor{ user, organization }viaPOST {centralAuth}/api/exchange-token, then mints the local session JWT withjoseSignJWT(algHS256, 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.
Cookie names
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):
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().
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 amemberof that org, the session resolves tonull— revoked users cannot retain stale merchant scope (per the code comment). - For a central session it reads
session.activeOrganizationIdand 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
sha256hash (hashApiKey) and a displaykeyPrefix(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 *.
Full scope list (API_SCOPES in api-auth.ts)
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:
Both paths converge on a single ApiContext so downstream handler code does not
care which method was used:
Wrap handlers with withAuth(handler, options) to get auth + the apiError
envelope for free:
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:
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 }andrequestIdto 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. Inapps/mvs-pay-extensions/src/routes/payments.tsthe PHI audit call passesuserId: 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-authlands, every route adds JWT validation alongsidex-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:
- Layer 1/2 resolve it into
ApiContext.organizationId(from the session’splatformOrg.idor the API key’sorgId). - Layer 3 carries it across the wire as
x-org-idand the middleware lands it asc.get("tenant").organizationId. - Extensions handlers must put it in every Prisma
where. Examples inapps/mvs-pay-extensions/src/routes/plaid.ts:
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.
The rationale and migration plan for the Layer-3 model.
The Hono service that lives behind the internal secret.
Single shared schema; how organizationId threads through tables.
Quarterly rotation of MVS_PAY_EXTENSIONS_INTERNAL_SECRET.
Quick reference: which layer am I in?
See also: Architecture, Merchant & Operator UI, Secrets Management.