OPCStack uses Better Auth for authentication. Auth state lives in the global Meta DB, not in tenant shards, because the system must resolve a user identity without knowing their shard. Multiple sign-in methods are supported: email with password, email OTP verification, Google, GitHub, and LinuxDo OAuth.
Auth Model
src/backend/api/auth/index.ts exports authCore(env, db), which builds the Better Auth instance. The instance combines several Better Auth plugins:
bearerenables Bearer token access for API clientsemailOTPhandles verification codes and password reset flowscaptchaadds Cloudflare Turnstile protection on sign-up and password reset endpointsgenericOAuthpowers the LinuxDo social login
Email and password is not a plugin. It is a native Better Auth provider configured in the emailAndPassword field. Google and GitHub are native social providers configured in socialProviders.
Session expiry is 30 days with a refresh at 27 days. The password hasher uses the Workers runtime crypto.subtle with SHA-1 and a random salt, not the pure-JS fallback.
Request Routing
All auth-related HTTP goes through the /api/* prefix. Three layers of middleware run in order before your handler:
Client request
|
v
/metaDbSessionMiddleware (/api/*)
-- injects ctx.get('metaDb') with a D1 session bookmark
|
v
/emailAuthMiddleware (/api/auth/*)
-- blocks OTP sign-in, checks signup, allowlist, cooldown
|
v
Better Auth handler (/api/auth/*)
-- delegates to authCore().handler
|
v
/authMiddleware + betaGateMiddleware + tenantDbMiddleware (/api/*, authenticated routes)
-- validates session, sets userId, checks beta code, attaches tenantDb
The public API chain (publicApi) only mounts emailAuthMiddleware for /api/auth/* paths. Protected JSON routes accept either a Better Auth browser session or an OAuth Bearer token and validate their registered business scope. Administrator routes also run administratorMiddleware against the current D1 role.
Auth Data Ownership
Auth schema is defined in src/backend/db/schema.auth.ts and lives in the Meta DB. Four tables:
| Table | Purpose |
|---|---|
user |
User identity: id, name, email, emailVerified, image, affCode, registrationUtmSource |
session |
Session token, expiry, userId reference |
account |
Provider accounts: email/password, Google, GitHub, LinuxDo. Multiple per user |
verification |
Email OTPs and verification tokens |
Auth tables do not live in tenant shards because the request layer needs to resolve identity before knowing the shard. account supports multiple rows per user, one per provider.
Sign-in Methods
Email and Password
Controlled by the Authentication and Email configuration documents in Meta D1. registrationEnabled gates every first-time account creation, including social OAuth. Email/password registration does not require an Email provider unless verification is required. When verification is required, the frontend calls sendVerificationOtp after signup to send a 6-digit code.
User enters email + password
|
v
POST /api/auth/sign-up/email
-- creates user when registration is enabled
|
v
POST /api/auth/email-otp/send-verification-otp (if verification is enabled)
-- sends 6-digit OTP to email
|
v
POST /api/auth/email-otp/verify-email
-- marks emailVerified=true
The password hasher in buildPasswordHasher uses crypto.subtle.digest('SHA-1', ...) with a random 8-byte salt. The hash format is saltHex:keyHex.
OAuth API Access
The template includes one fixed public OAuth client, opc-cli. It uses Authorization Code with PKCE, so it has no client secret. The CLI owns device authorization polling, code exchange, refresh rotation, and credentials stored by connection name.
Every protected JSON route declares one business scope directly beside its route registration in src/backend/api/index.ts. The valid OAuth scope vocabulary lives in src/backend/api/auth/oauth-api-access.ts. authMiddleware accepts a browser session or OAuth Bearer token. requireApiScope(scope) lets browser sessions pass and requires OAuth grants to contain the route scope. Administrator and configuration scopes also run administratorMiddleware against the current D1 role.
opc auth connect --name shop-prod --server https://app.example.com --scopes config:ai:read,config:ai:write
opc api request --name shop-prod --method POST --url /api/admin/get_ai_config --body '{}'
opc auth status --name shop-prod
opc auth disconnect --name shop-prod
opc api request only accepts relative paths, injects the Bearer token, rejects caller-supplied Authorization, and refreshes the named connection when required. A failed refresh removes only that connection.
/oauth/authorize and /oauth/consent are the browser authorization pages. Consent shows the client name, target project origin, and requested business scopes. The fixed transport scopes api_access offline_access are protocol details.
The Settings page lists API grants together with account security controls. Revoking one grant does not sign the browser out. It blocks existing access tokens through the D1 grant check and revokes every refresh token tied to the grant.
Email OTP
Email OTP is a plugin-level feature, not a login method. disableSignUp: true in the plugin config means OTP cannot create a new user. The OTP sign-in route is hard-blocked by emailAuthMiddleware:
if (ctx.req.path === '/api/auth/sign-in/email-otp') {
return ctx.json({ code: 'EMAIL_OTP_SIGN_IN_DISABLED' }, 400)
}
OTP is used for email verification after signup and password reset. The OTP is 6 digits, expires in 300 seconds, and allows 3 attempts. OTPs are stored hashed.
Google OAuth
Enabled in the Authentication configuration. The client ID and secret configure the native Google social provider. Redirect URI is https://your-domain.com/api/auth/callback/google.
GitHub OAuth
Enabled in the Authentication configuration. Same native social provider pattern as Google. Redirect URI is https://your-domain.com/api/auth/callback/github.
LinuxDo OAuth
Enabled in the Authentication configuration. Uses the genericOAuth plugin because LinuxDo is not a native Better Auth provider. Profile mapping converts the LinuxDo user id into a synthetic email:
email: `linuxdo-${id}@linuxdo.local`
This email is not real. It only ensures uniqueness in the user table. The mapProfileToUser function also builds the avatar URL from the avatar_template field.
Platform Setup
Use APP_BASE_URL as the source of truth for callback URLs. In production it resolves to https://<APP_DOMAIN>. In local dev with APP_DOMAIN=localhost, it resolves to http://localhost:5173.
| Platform | Dashboard action | OPCStack configuration |
|---|---|---|
| Cloudflare Email Service | Onboard the sending domain, then use the generated SEND_EMAIL binding |
Email tab: Cloudflare provider; administrator email as sender |
| Resend | Verify the sending domain, create an API key with sending permission | Email tab: Resend provider and API key; administrator email as sender |
| Cloudflare Turnstile | Create or reuse a widget for APP_DOMAIN and optional APP_CN_DOMAIN |
Authentication tab: enabled switch |
| Google OAuth | Create a Web application OAuth client | Authentication tab: client ID, client secret, and enabled switch |
| GitHub OAuth | Create an OAuth App | Authentication tab: client ID, client secret, and enabled switch |
| LinuxDo OAuth | Create an OAuth application in the LinuxDo console | Authentication tab: client ID, client secret, and enabled switch |
Cloudflare Email Service
The Worker already has a send_email binding named SEND_EMAIL in wrangler.jsonc.tpl; do not add another binding.
Platform steps:
- Open Cloudflare dashboard.
- Go to Compute > Email Service > Email Sending.
- Select Onboard Domain and choose the domain used by the administrator email.
- Let Cloudflare create the required SPF, DKIM, DMARC, and bounce records.
- Wait until the sending domain is active.
- Set
SYSTEM_EMAILto an address on the onboarded domain before the first preparation. - Open the admin System settings workspace, select the Email tab, choose Cloudflare, and save.
Use Resend if you need local real delivery without depending on Cloudflare remote email behavior.
Docs: Cloudflare Email Service, Workers send email binding
Resend
Platform steps:
- Open Resend dashboard.
- Add and verify the sending domain.
- Create an API key with sending access.
- Set
SYSTEM_EMAILto an address on the verified domain before the first preparation. - Open the admin System settings workspace and select the Email tab.
- Choose Resend, enter the API key, and save.
The from address is always the D1 administrator email created during first preparation. If Resend rejects mail, fix the sender domain and initialize a fresh deployment with the correct SYSTEM_EMAIL.
Docs: Resend domains, Resend API keys
Cloudflare Turnstile
On first production initialization, prepare-cloudflare creates or reuses one Turnstile widget named APP_NAME for APP_DOMAIN and optional APP_CN_DOMAIN. It seeds the site key and encrypted secret into the disabled Authentication configuration. Local initialization uses Cloudflare test credentials.
Normal production steps:
- Deploy the application.
- Open the Authentication tab in the admin System settings workspace.
- Enable Turnstile and save. The generated credentials are already present.
Manual setup steps:
- Open Cloudflare dashboard.
- Go to Turnstile.
- Create a widget.
- Add
APP_DOMAINand optionalAPP_CN_DOMAINas allowed hostnames. - Open the Authentication tab, enable or disable Turnstile, and save.
Turnstile is attached to email sign-up, email sign-in, and password reset request endpoints.
Docs: Turnstile widget management, Turnstile testing keys
Google OAuth
Callback URL:
<APP_BASE_URL>/api/auth/callback/google
Production example:
https://app.example.com/api/auth/callback/google
Local example:
http://localhost:5173/api/auth/callback/google
Platform steps:
- Open Google Cloud Console.
- Select or create a project.
- Configure the OAuth consent screen.
- Create an OAuth client of type Web application.
- Add the callback URL above to Authorized redirect URIs.
- Add the app domain to Authorized domains when Google requires it.
- Open the Authentication tab in the admin System settings workspace.
- Enter the client ID and client secret, enable Google, and save.
Google requires the redirect URI to match exactly. A scheme, host, port, or path mismatch returns redirect_uri_mismatch.
Docs: Google OAuth web server flow, Google redirect URI setup
GitHub OAuth
Callback URL:
<APP_BASE_URL>/api/auth/callback/github
Production example:
https://app.example.com/api/auth/callback/github
Local example:
http://localhost:5173/api/auth/callback/github
Platform steps:
- Open GitHub.
- Go to Settings > Developer settings > OAuth Apps.
- Create a new OAuth App.
- Set Homepage URL to
APP_BASE_URL. - Set Authorization callback URL to the callback URL above.
- Open the Authentication tab in the admin System settings workspace.
- Enter the client ID and generated client secret, enable GitHub, and save.
GitHub OAuth Apps have one callback URL. Use a separate OAuth App for local and production if both need to work at the same time.
Docs: GitHub OAuth App setup
LinuxDo OAuth
Callback URL:
<APP_BASE_URL>/api/auth/oauth2/callback/linuxdo
Production example:
https://app.example.com/api/auth/oauth2/callback/linuxdo
Local example:
http://localhost:5173/api/auth/oauth2/callback/linuxdo
Platform steps:
- Open the LinuxDo OAuth application console.
- Create an OAuth application.
- Set the callback URL above.
- Open the Authentication tab in the admin System settings workspace.
- Enter the client ID and client secret, enable LinuxDo, and save.
The runtime uses these LinuxDo endpoints directly:
https://connect.linux.do/oauth2/authorize
https://connect.linux.do/oauth2/token
https://connect.linux.do/api/user
LinuxDo does not provide a real email to this app. The mapped user email is synthetic, so password reset and email verification are not valid LinuxDo account flows.
Email Rules
emailAuthMiddleware in src/backend/api/middleware/email-auth.ts runs on all /api/auth/* requests and enforces five rules:
-
OTP sign-in block. The route
/api/auth/sign-in/email-otpalways returns 400EMAIL_OTP_SIGN_IN_DISABLED. -
Registration gate. Email signup is rejected with
REGISTRATION_DISABLEDwhen registration is off. Better Auth's user creation hook applies the same rule to first-time social OAuth users. -
Email provider availability. Verification, password reset, and other email actions return
EMAIL_PROVIDER_UNAVAILABLEwhen no Provider is configured. The login page derives the same state and hides the forgot-password link. Account email changes are disabled. -
Domain allowlist. The Authentication configuration stores the allowed signup domains as a list. Empty means all domains are allowed. A signup email whose domain is not in the list gets 400
EMAIL_DOMAIN_NOT_ALLOWED. -
Per-email cooldown. Each (scene, email) pair has a cooldown window controlled by the Authentication configuration. The cooldown is tracked in both a local in-memory
Mapand in KV. The KV key isemail:cooldown:{scene}:{sha256(email)}. Within the cooldown, returns 429EMAIL_ACTION_RATE_LIMITED.
User Creation Side Effects
When Better Auth creates a new user, two hooks run in authCore:
create.before runs before the user row is written:
- Rejects all first-time account creation with
REGISTRATION_DISABLEDwhen registration is off - Calls
aff.createCode()to generate a unique affiliate code for the new user - Reads the
registration_utm_sourcecookie from the request headers - Adds both fields to the user record
create.after runs after the user row is written:
- Calls
createTenantShardAccess(env, db).openUserDb(userId, region)to assign the user to a tenant shard. The region is resolved fromrequest.cf.continentusing the same mapping as the shard router:AS -> apac,EU -> weur,OC -> oc, defaultapac - Creates the user's credit balance in the tenant shard DB via
CreditsService.createBalance - Reads one Credits configuration snapshot from Meta D1 and grants the configured signup reward with
sourceType: 'signup'andsourceId: userIdwhen enabled
This means auth creation is a cross-DB flow: User row in Meta DB, shard assignment in Meta DB (user_shards), credit balance in Tenant Shard DB. There is no cross-DB transaction. If the process crashes between Meta write and Shard write, the user exists but has no balance row. The signup grant is idempotent by source_type + source_id.
Session Management
Sessions are stored in the session table in Meta DB. Better Auth handles session creation and verification. The bearer plugin enables Bearer token access: clients can pass Authorization: Bearer <token> instead of a cookie.
D1 read consistency is handled by metaDbSessionMiddleware, which runs on all /api/* routes. It resolves a session bookmark from the request header x-d1-meta-bookmark or the cookie d1_meta_bookmark, defaulting to first-primary. After the request completes, it writes the next bookmark back to the response header and cookie.
Request with bookmark header/cookie
|
v
metaDbSessionMiddleware
-- resolves bookmark: header > cookie > 'first-primary'
-- attaches session-backed MetaDb to ctx
|
v
Handler reads/writes Meta DB
|
v
middleware writes next bookmark to response header + cookie
Session expiry is 30 days. Better Auth refreshes the session after 27 days of activity.
Route Protection
authMiddleware
src/backend/api/middleware/auth.ts. Calls authCore().api.getSession() with the request headers. If no session, returns 401 UNAUTHORIZED. If session exists, sets ctx.set('userId', session.user.id) and calls next().
betaGateMiddleware
src/backend/api/middleware/beta-gate.ts. If the Authentication configuration enables the beta gate, queries betaCode in Meta DB for a row where usedBy === userId. If none found, returns 403 BETA_CODE_REQUIRED. If the feature is disabled, it passes through.
tenantDbMiddleware
src/backend/api/middleware/tenant-db.ts. Resolves the user's shard from user_shards in Meta DB, opens a D1 session with bookmark consistency, and sets ctx.set('tenantDb', ...) and ctx.set('tenantShardId', ...).
Admin Access
administratorMiddleware in src/backend/api/middleware/auth.ts checks the authenticated user's current D1 role for browser sessions and OAuth access. If the user is not the administrator, it returns 403 FORBIDDEN.
Frontend Integration
The frontend does not instantiate createAuthClient directly. It imports client from src/api-contract/client, which wraps createAuthClient with bookmark handling, token storage, and typed API methods.
import { client } from '$apiContract/client'
// Sign in with email
await client.auth.signIn.email({ email, password })
// Sign up with email
await client.auth.signUp.email({ email, password, name: email })
// Send OTP
await client.auth.emailOtp.sendVerificationOtp({ email, type: 'email-verification' })
// Verify email
await client.auth.emailOtp.verifyEmail({ email, otp })
// Social sign in
await client.auth.signIn.social({ provider: 'google' })
await client.auth.signIn.social({ provider: 'github' })
await client.auth.signIn.oauth2({ providerId: 'linuxdo' })
// Session
const session = client.auth.useSession()
// Sign out
await client.auth.signOut()
Pre-built auth UI components live in src/frontend/lib/app-ui/auth/:
LoginCardhandles email login and social buttonsRegisterCardhandles email signup and triggers OTP if requiredOtpCardhandles OTP input and resend with cooldownForgotPasswordCardandResetPasswordCardhandle password resetTurnstilewraps the Cloudflare Turnstile widgetUserMenushows the session user and sign-out
These components read feature flags from clientConfig to decide which sign-in methods to render.
Configuration
Authentication and Email configuration live only in the Meta D1 system_settings row. Open the admin System settings workspace, edit one tab, and explicitly save it. Each successful save validates the complete domain and becomes effective for subsequent requests without redeployment.
The Authentication tab owns registration policy, the beta gate, email verification, the Turnstile switch, and Google, GitHub, and LinuxDo credentials. Turnstile credentials are initialized during deployment and cannot be edited from this page. The Email tab owns the Provider and Resend API key. Provider presence is the single source of email availability; there is no separate email switch. Secret reads expose only whether a value is configured; replacing or removing an OAuth or email secret is an explicit save action.
The administrator identity, public support address, and email sender come from the unique D1 administrator account. The first preparation creates it from SYSTEM_EMAIL, generates a random password, and prints the credentials once. Later preparations do not overwrite the email or password. BETTER_AUTH_SECRET and CONFIG_ENCRYPTION_KEY are generated by prepare-cloudflare; users do not configure them.
Common Mistakes
Trying to sign in with OTP. The route /api/auth/sign-in/email-otp is hard-blocked by middleware. OTP is only for verification and password reset, not login.
Assuming LinuxDo users have a real email. The email is synthesized as linuxdo-{id}@linuxdo.local. Email-based features like password reset do not work for LinuxDo users.
Using an unverified sender for the first administrator. Set SYSTEM_EMAIL to an address on the verified sending domain before the first preparation. Later preparations never overwrite the D1 administrator email or password.
Expecting a cross-DB transaction on signup. User creation writes to Meta DB and Tenant Shard DB separately. If the process crashes between them, the user exists but has no credit balance. This is by design.
Passing registration_utm_source as a URL parameter. The hook reads it from the registration_utm_source cookie, not from the request URL.