Skip to content

Authentication

Conventional Auth is chosen by one variable, MANTLE_AUTH_MODE, and fails closed when its configuration is incomplete. This page covers the two modes, the secrets each needs, first-owner bootstrap, roles, the routes that require a session, and the curated Better Auth surface.

Mode matrix

ModeNon-secret varsWorker secretsMust be absent
self-managedMANTLE_AUTH_MODE=self-managed, PUBLIC_ORIGIN, GITHUB_CLIENT_ID, ADMIN_GITHUB_LOGINGITHUB_CLIENT_SECRET, BETTER_AUTH_SECRETMANTLE_HOSTED_AUTH_ISSUER, MANTLE_HOSTED_AUTH_CLIENT_ID
hostedMANTLE_AUTH_MODE=hosted, PUBLIC_ORIGIN, MANTLE_HOSTED_AUTH_ISSUER, MANTLE_HOSTED_AUTH_CLIENT_ID, ADMIN_GITHUB_LOGINBETTER_AUTH_SECRETGITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET

Validation rules:

  • PUBLIC_ORIGIN is the site's HTTPS origin without a trailing slash. When unset, the adapter falls back to http://localhost:8787.
  • Self-managed uses the site's own GitHub OAuth app. Register its callback URL as <PUBLIC_ORIGIN>/api/auth/callback/github.
  • Hosted is a public PKCE client with no client secret. MANTLE_HOSTED_AUTH_ISSUER must be an HTTPS root origin (no path, query or fragment; http only for loopback). MANTLE_HOSTED_AUTH_CLIENT_ID must be a URL on that same origin shaped /clients/<id>.
  • ADMIN_GITHUB_LOGIN must be a valid GitHub login.

Any missing, invalid, partial or mixed-mode configuration produces a setup-incomplete Auth instance instead of a working one.

503 setup_incomplete

With incomplete Auth, public routes keep working: public Views, public HTTP Triggers, public pages, .md mirrors, llms.txt and the sitemap. Auth-owned private routes return:

json
{ "error": "setup_incomplete", "message": "Self-managed Auth configuration errors: BETTER_AUTH_SECRET is not set; ..." }

with status 503 and private, no-store. The affected paths are /admin and /admin/*, /api/auth and /api/auth/*, /oauth and /oauth/*, /.well-known/oauth*, /mcp and /mcp/*. The message lists every failing check.

Secrets and local values

sh
wrangler secret put BETTER_AUTH_SECRET
wrangler secret put GITHUB_CLIENT_SECRET   # self-managed only

Non-secret vars go in wrangler.jsonc under vars. For local development put the same names in .dev.vars, which the minimal reference ignores in git (.dev.vars*). Never commit a secret.

First owner

Every new user receives the default role user, which has no staff access. The first sign-in whose GitHub login matches ADMIN_GITHUB_LOGIN is promoted to owner. Promotion is blocked once any staff user exists, so the variable only bootstraps an empty site.

Roles

Staff roles are owner, editor and contributor, in descending order. Owners manage them in Admin; the underlying routes are:

RouteMinimum role
GET /admin/api/staff, PATCH /admin/api/staff/:id/role (a role or null to revoke)owner
POST /admin/api/staff/invitations, DELETE /admin/api/staff/invitations/:idowner
GET /admin/api/site-settings, PATCH /admin/api/site-settingsowner
GET /admin/api/memberseditor
POST /admin/api/entries/:id/publish, POST /admin/api/entries/:id/unpublish, DELETE /admin/api/entries/:ideditor

The staff role is re-read from D1 on every protected REST and MCP call; a revoked role takes effect on the next request. Manifest-level rules such as requires.auth are covered in Authorization and the authorization reference.

What needs a session

SurfaceRequirement
/admin, /admin/api/*Staff session; role gates per route
/api/auth/*, /oauth/*, /.well-known/oauth*Auth-owned; public endpoints of the OAuth flow
/mcpAny authenticated OAuth caller; anonymous requests get 401 with a WWW-Authenticate challenge
/mcp/staffAuthenticated caller with a staff role
/<locale>/<segment>/<slug>?preview=1Staff session (401 without a session, 403 without a staff role)
Public Views, public HTTP Triggers, public pages, .md, llms.txt, sitemapNone, unless the manifest declares requires

MCP tokens are session-bound: signing out of Admin ends MCP access. See MCP and agents.

Curated Better Auth surface

createAuth() exposes curated fields, not a Better Auth passthrough: database, baseURL, secret, methods, bootstrapOwner, oauthProvider, rateLimit, and for first-party SSO trustedOrigins, cookiePrefix and crossSubDomainCookies. There is no betterAuthOptions or advanced escape hatch; a new Better Auth knob appears only when a Mantle use case justifies a first-class field.

When several first-party apps share one parent domain that the same party controls, configure shared cookies explicitly. cookiePrefix is required whenever more than one Better Auth app writes cookies under that domain; trustedOrigins is the auth-flow trust list, not a CORS policy.

ts
const auth = createAuth({
  database: env.DB,
  baseURL: "https://platform.example.com",
  secret: env.BETTER_AUTH_SECRET,
  methods,
  trustedOrigins: ["https://example.com", "https://www.example.com"],
  cookiePrefix: "example-platform",
  crossSubDomainCookies: { enabled: true, domain: "example.com" },
});

Shared cookies do not cross registrable domains. A browser never sends an example.com cookie to customer.com. For a customer-owned domain, use an OAuth/OIDC broker flow: the customer site redirects to the identity provider's authorize endpoint, receives the callback, verifies the response and creates its own local session. The broker returns identity; the customer site remains the authority for its members and grants.

Self-hosted and hosted

A free self-hosted site runs every method createAuth() exposes: Better Auth social providers, email OTP, magic link, and parent-domain SSO. The owner supplies provider credentials, email sending and cookie policy. Hosted auth is an operations convenience: the platform holds provider and email configuration and registers the site as a PKCE client, while the site still owns grants, members, content and ctx.user/ctx.staff mapping. Neither mode changes the runtime's authorization vocabulary.

Replacing Auth construction

ts
createMantleWorker({
  plan,
  auth: (env) => createAuth({ /* curated, site-specific methods */ }),
});

With auth set, MANTLE_AUTH_MODE and the mode variables are not read. Core still owns the Auth routes: the factory's basePath joins the reserved paths, the MCP resource defaults to auth.mcpResource ?? <PUBLIC_ORIGIN>/mcp, and a rejected auth.ready evicts the isolate's assembly. Keep the D1 DB binding; Better Auth tables live there.

OAuth resource primitives

When one Mantle site is an OAuth client of another, request a stable RFC 8707 resource and use standard offline_access when refresh is needed:

ts
const clientAuth = createAuth({
  // database, baseURL, secret, other methods...
  methods: [{
    kind: "oauth",
    providerId: "mantle-platform",
    clientId: env.PLATFORM_CLIENT_ID,
    discoveryUrl: "https://platform.example.com/api/auth/.well-known/openid-configuration",
    scopes: ["openid", "offline_access", "accounts:read"],
    resource: "https://api.example.com",
  }],
});

const { accessToken, accessTokenExpiresAt, scopes } =
  await clientAuth.getProviderAccessToken(request, "mantle-platform");

The server-side getter is bound to the current local session request and never returns a refresh token or account row. On the provider:

ts
const providerAuth = createAuth({
  // database, baseURL, secret, methods...
  oauthProvider: {
    loginPage: "/sign-in",
    consentPage: "/consent",
    scopes: ["openid", "offline_access", "accounts:read"],
    resources: ["https://api.example.com"],
  },
});

const verification = await providerAuth.verifyOAuthAccessToken(request, {
  audience: "https://api.example.com",
  scopes: ["accounts:read"],
});

The verifier accepts JWT access tokens only and checks the configured issuer, JWKS/signature, audience, time claims, required scopes, and—when passed the request—DPoP proof binding with database-backed replay protection. It returns only userId, clientId, credentialId, and scopes. Opaque tokens are rejected; there is no introspection fallback.

Source