Skip to content

Authentication and enterprise identity

Better Auth, organizations, OAuth 2.1, SSO, SCIM, and MCP authorization.

Updated View as Markdown

The authorization server is Better Auth at https://api.stackship.run/api/auth.

Browser clients do not call that origin directly. They use the dashboard’s same-origin https://stackship.run/api/auth/* boundary. The dashboard forwards the request to the issuer and rewrites the Better Auth session cookie as a host-only stackship.run cookie. It also round-trips only Better Auth’s signed, short-lived OAuth and SAML state cookies so callback validation can complete; unrelated browser and upstream cookies are dropped. Every returned auth cookie is forced host-only, and cross-subdomain cookies are disabled.

The API’s credentialed CORS allow-origin is derived from the deployed APP_URL. Production therefore allows https://stackship.run only; a localhost development origin is accepted only by a local API whose own APP_URL is that localhost origin.

Every dashboard and documentation route returns the same baseline browser-security protections as the API and MCP Workers. The shared policy requires same-origin opener and resource isolation, blocks cross-origin framing and MIME sniffing, and enables HTTP Strict Transport Security for stackship.run and its subdomains. The dashboard sends referrer information only to the same origin with Referrer-Policy: same-origin; the documentation, API, and MCP Workers use the stricter Referrer-Policy: no-referrer.

Mutation boundaries validate the browser’s Origin header independently of the referrer policy. Referrer policy controls the Referer header and does not create, preserve, or authorize an Origin value.

OpenNext applies the dashboard policy to dynamic responses through the Next route configuration. Cloudflare Static Assets applies the corresponding non-CSP baseline to prerendered pages, public files, and immutable build assets through each surface’s checked-in public/_headers manifest. The static wildcard intentionally does not attach a document Content Security Policy to JavaScript, stylesheets, or other immutable assets.

The same-origin boundary validates each mutation body completely before contacting Better Auth. General authentication requests are limited to 1 MiB; SAML assertion-consumer form posts are limited to 256 KiB. Invalid, conflicting, or oversized declared lengths fail before a body is read, while unknown-length streams use a fixed-size buffer and are cancelled on overflow. The forwarded Content-Length is always the validated byte count.

Enabled capabilities

  • Email/password sign-up and sign-in
  • Google OAuth login
  • Organization creation, membership, roles, and active organization sessions
  • Enterprise OIDC and SAML through @better-auth/sso
  • SCIM 2.0 through @better-auth/scim
  • OAuth 2.1 and OIDC through @better-auth/oauth-provider
  • JWT signing and a public JWKS endpoint

The dashboard verifies that the session’s active organization still has a live membership before it renders product data. If a deleted membership or stale session points at an unavailable organization, every general, workspace, and run admission layout returns to the organization selector. It does not render the control-plane error boundary or continue loading tenant data. The user must select another live organization; StackShip never trusts or silently reuses the stale organization identifier.

Email/password accounts must verify their address before sign-in. Verification is sent automatically after sign-up, and a successful password reset revokes the account’s other sessions. Verification, password reset, and organization invitation requests await Cloudflare Email Service acceptance; if delivery is unavailable, the auth operation fails with a retryable, non-enumerating public response. The dashboard presents the same generic instruction whether the address is unknown, ineligible, already verified, provider-suppressed, or temporarily undeliverable; the user can retry if the message does not arrive, while the underlying failure remains available to operators.

The admin plugin is enabled because SCIM’s active property uses its banned state to deactivate users and revoke sessions. Synthetic sign-up responses include the same admin fields as stored users, so an existing address cannot be identified by comparing response shapes.

Better Auth rate limiting is enabled explicitly instead of depending on NODE_ENV, which is not a reliable production signal inside a Worker. Counters are stored in PostgreSQL rather than an isolate-local map, so attempts remain bounded across Worker isolates and regions. The client key is derived only from Cloudflare’s CF-Connecting-IP header and the authentication route; caller-supplied forwarding headers are not trusted. Better Auth’s sensitive defaults allow at most three sign-in, sign-up, password-change, or email-change attempts per client and route in ten seconds, and at most three password-reset or verification-email requests per minute.

Recent step-up authentication

Sensitive human downloads use Better Auth’s session-freshness contract. freshAge is exactly 15 minutes. The API resolves the authoritative database session with Better Auth cookie caching and refresh disabled, and treats the session’s creation time as step-up evidence only when it is no more than 15 minutes old and not implausibly in the future. Missing, malformed, stale, or client-supplied timestamps fail closed with STEP_UP_REQUIRED.

The dashboard’s Verify identity action returns the operator to the normal sign-in surface in step-up mode. Email/password, Google, OIDC, and SAML all create a new Better Auth session, so the same production identity controls apply to initial authentication and step-up. Step-up mode cannot create an account. If the new session has no active organization, the ordinary organization selector resumes only the exact allowlisted run-review or workspace-state path after selection. The restricted operation re-evaluates the current organization, membership, permissions, new session age, and typed reason before access.

Public account flows

The sign-in page discovers only two public booleans: whether Google is fully configured and whether the enterprise SSO runtime is available. The discovery response is not cached and contains no client IDs, provider domains, secrets, or connection records. If discovery fails, Google and SSO controls are hidden instead of guessing that they are available.

Google uses this registered production callback:

https://stackship.run/api/auth/callback/google

Enterprise OIDC uses this shared production callback:

https://stackship.run/api/auth/sso/callback

Enterprise discovery accepts a work email. A missing connection and an identity-provider failure receive the same generic dashboard treatment; the public UI does not confirm whether a domain has a connection. A sign-in action that returns no usable outcome also stays on the sign-in page and shows that same generic failure rather than routing the browser to the dashboard.

Password recovery and verification requests also return generic outcomes. The dashboard never confirms whether an email address exists, is already verified, or is eligible for an account. Better Auth validates a reset link before sending its token to the dashboard’s minimal /reset-password/callback handoff. That endpoint immediately moves a valid token into a URL fragment and redirects to /reset-password, so the application document and its subresources never receive a token query. The reset form reads the fragment into memory, removes it from browser history before rendering, never writes it to Web Storage, and clears password fields after a submission. Reset links are single-use, and a successful reset revokes the account’s existing sessions.

Organization invitation links carry only an opaque invitation ID. Signed-out users return to that exact invitation after authentication; no arbitrary return URL is accepted. Better Auth still verifies the invitation, its status, its expiry, and the signed-in email. Better Auth creates the membership and sets that invitation’s organization active in the same acceptance operation. The database permits only one membership for a given user and organization, so concurrent invitation, SSO, or SCIM provisioning cannot leave conflicting role records for the tenant resolver. The dashboard does not trust an organization ID from the browser or issue a second activation mutation after acceptance.

Allowed post-authentication returns are /, /dashboard, an /accept-invitation path containing one syntactically valid invitation ID, and the exact run-review or workspace-state path that initiated step-up. Cross-origin, protocol-relative, token-bearing, and unsupported return paths fail closed to /dashboard. A sign-in request without an explicit return also enters /dashboard; an explicit / remains available for callers that intentionally want the public home page.

Identity recovery pages do not emit product analytics and must not log or persist reset tokens, verification tokens, SAML assertions, authorization codes, or passwords.

Transactional email privacy

Cloudflare Email Service is the only transactional transport. StackShip sends from no-reply@stackship.run through a restricted Worker binding; application code contains no SMTP password or Email Service API token.

Delivery records contain a template ID, a non-secret object reference, a hashed delivery identifier, Cloudflare’s provider message ID, status, attempt count, and timestamps. They never contain the recipient address, rendered body, verification URL, reset URL, or bearer token. A provider-accepted delivery is terminal and deduplicated. If acceptance cannot be determined, StackShip marks it for operator reconciliation and does not automatically send the bearer link again. Provider suppressions are terminal and are not bypassed.

OAuth and MCP

The MCP resource identifier is https://mcp.stackship.run/mcp. Clients discover it through https://mcp.stackship.run/.well-known/oauth-protected-resource.

The authorization server requires PKCE for public clients, supports dynamic client registration for MCP clients, and issues audience-bound JWT access tokens. MCP tools require the exact https://mcp.stackship.run/mcp audience, the mcp:tools scope, and an organization claim. Mutating MCP tools additionally require the mcp:write scope, which dynamically registered clients must request explicitly; it is never granted by default. Tokens issued only for the API audience fail closed.

OAuth authorization-server metadata remains at https://api.stackship.run/.well-known/oauth-authorization-server/api/auth. Better Auth’s matching startup reminder is the only OAuth Provider warning acknowledged in configuration; unrelated warnings remain enabled.

At authorization, StackShip requires an active organization. If the new OAuth session does not have one, the user must select one of their organizations before MCP consent. That organization becomes active on the OAuth session, is bound to the consent, and is copied into the access-token claim. An MCP authorization attempt without an organization fails closed. StackShip also rechecks the signed-in user’s live membership before reusing an active organization for post-login selection, consent, or organization-owned dynamic client registration. A stale active-organization value left on another session after membership removal cannot mint new organization-bound OAuth authority. Changing organizations requires a new authorization; the MCP server never infers a tenant from request input.

The consent page never presents caller-supplied client_name or organization query values as authoritative labels. It shows the protocol client ID and describes the organization generically as the active StackShip organization; Better Auth’s signed authorization request and the live membership check remain the authority for the eventual grant.

MCP access is organization-bound. Access tokens are signed JWTs that expire after one hour. A client that requests offline_access can also receive a hashed, opaque refresh token that expires after 30 days. Refresh-token use rotates the refresh token and marks the previous value revoked. Revoking a refresh token prevents it from minting another access token, but an MCP JWT already issued from that authorization remains valid until its one-hour expiry; the MCP Worker verifies the JWT locally and has no token revocation list.

The fixed tofu-cli client receives a hashed, opaque access token that expires after 90 days and does not receive a refresh token. OpenTofu requests are not bound to the organization that happened to be active during consent. Each request instead resolves its organization from the cloud protocol request and requires the token’s user to retain a currently authorized membership there. The OAuth revocation endpoint deletes the opaque access-token record, so subsequent OpenTofu requests fail immediately.

Better Auth stores consent, opaque-token, and refresh-token protocol records. StackShip does not currently copy consent decisions, token issuance, refresh, or revocation into its product audit log, and the dashboard does not currently provide an organization-settings control for revoking OAuth clients.

The initial compatibility mode permits unauthenticated registration of public clients because common MCP clients rely on RFC 7591 dynamic registration. This must be reviewed as Client ID Metadata Documents and MCP registration guidance stabilize.

Required production secrets

Secret Purpose
BETTER_AUTH_SECRET Session and token cryptography
CLOUD_TOKEN_HMAC_KEY HMAC for OpenTofu service-account tokens
GOOGLE_CLIENT_ID Google OAuth client
GOOGLE_CLIENT_SECRET Google OAuth client secret

CLOUD_TOKEN_HMAC_KEY must be an independent secret of at least 32 bytes. It must not reuse BETTER_AUTH_SECRET. The API also requires a HYPERDRIVE binding. Never store these values in Wrangler configuration or source control.

API readiness treats production identity configuration as one fail-closed gate. It requires a nonblank Better Auth secret, the complete nonblank Google client ID and secret pair, the restricted Cloudflare Email binding, and a cloud-token HMAC key of at least 32 UTF-8 bytes that differs from the Better Auth secret. An incomplete configuration returns 503 with the coarse state identity: configuration_required; the response never identifies a missing secret or includes configuration values.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close