Authentication Security
Configure secure cookies, rate limiting, and cross-domain authentication. Learn best practices for production-ready auth configuration.
Quickback enforces secure authentication defaults out of the box. This page explains how cookie security, rate limiting, and cross-domain authentication work, plus how to configure them for your use case.
Cookie Security
Quickback sets secure cookie defaults to protect against CSRF, XSS, and man-in-the-middle attacks.
Default Attributes
auth: defineAuth("better-auth", {
advanced: {
defaultCookieAttributes: {
sameSite: 'lax', // CSRF protection
secure: true, // HTTPS only
httpOnly: true, // No JavaScript access
}
}
})What each attribute does:
| Attribute | Value | Purpose |
|---|---|---|
sameSite | lax | Blocks cookies on cross-site POST requests (CSRF protection) |
secure | true | Cookies only sent over HTTPS (prevents interception) |
httpOnly | true | JavaScript cannot access cookies via document.cookie (XSS mitigation) |
SameSite Options
lax(recommended): Blocks cross-site POST requests but allows top-level navigation. Best balance of security and usability.strict: Blocks cookies on all cross-site requests. More secure but may break legitimate flows (e.g., returning from payment provider).none: Allows all cross-site requests. Required for cross-domain authentication (requiressecure: true).
Development Override
For local development over HTTP, you can relax the secure flag:
auth: defineAuth("better-auth", {
advanced: {
defaultCookieAttributes: {
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
}
}
})Warning: Never use secure: false in production.
Session Freshness and Password Reset
Two Better Auth session controls are emitted with stricter values than the library's own defaults:
| Setting | Quickback default | Better Auth default | Override |
|---|---|---|---|
session.freshAge | 15 minutes | 24 hours | session.freshAgeInHours |
emailAndPassword.revokeSessionsOnPasswordReset | true | false | emailAndPassword.revokeSessionsOnPasswordReset: false |
Freshness gates the sensitive account operations (change-password, delete-user, change-email confirmation): a session older than freshAge has to sign in again first. Better Auth's 24-hour window lets a day-old session on a shared machine delete the account; 15 minutes is the "full, valid login session" an assessor expects.
Reset revocation terminates every other session the moment a password is reset through the forgot-password flow. That is the point at which someone holding the old password should lose the sessions they opened with it. Password change from the Account UI already sends revokeOtherSessions: true; direct API callers pass that flag themselves.
auth: defineAuth("better-auth", {
session: { freshAgeInHours: 1 }, // loosen to an hour
emailAndPassword: { enabled: true, revokeSessionsOnPasswordReset: false }, // keep other sessions
})Signup Policy
signup decides who may create an account. One server-side gate (Better Auth's user.create.before) enforces it on every creation path — email + password, email OTP, magic link, and social sign-in — so no sign-in method can be used to slip past it.
auth: defineAuth("better-auth", {
signup: { inviteOnly: true }, // invitations + admin-created only
// or: signup: { allowedEmailDomains: ["example.com"] } // that domain + invitations
})| Always admitted | Why |
|---|---|
Accounts created by an admin (POST /auth/v1/admin/create-user) | Operators provision users directly |
| An email with a pending, unexpired organization invitation | Invitees must be able to sign up |
| The deploy owner's first Cloudflare login | The brokered-deploy owner is promoted to appmanager on that login |
Everyone else gets 403 with code SIGNUP_NOT_ALLOWED. Existing users sign in as before — the gate runs only when an account would be created. allowedEmailDomains matches the exact domain (example.com admits me@example.com, not me@sub.example.com).
account.auth.signup: false is the shorthand for inviteOnly: true. The compile fails if you combine inviteOnly with allowedEmailDomains (allowed domains already admit invitations), or a restricted policy with the upgradeAnonymous plugin (it lets an anonymous user claim any email).
Invitation acceptance itself still needs the invitation ID from the email, so knowing an invited address isn't enough to join an organization. Pair a restricted policy with emailVerification if accounts must prove their email before use.
A per-method hard block is separate: emailAndPassword.disableSignUp and the emailOtp / magicLink disableSignUp options refuse new accounts on that method even for invitees.
Account Linking
When someone signs in with a social provider and an account with the same email already exists, Better Auth links the two only if the provider verified the email (or is listed as trusted) and the existing account's email is verified. The second condition stops a pre-registered, unverified account from absorbing a real owner's Google sign-in. The compiler pins both conditions explicitly, so a future Better Auth default can't loosen them; the local-email condition is not configurable.
auth: defineAuth("better-auth", {
socialProviders: { google: { enabled: true } },
accountLinking: {
trustedProviders: ["google"], // trust Google's email even when it says unverified
// enabled: false, // never link implicitly; users link while signed in
},
})trustedProviders entries must be enabled socialProviders; anything else fails the compile.
Sign-In Tokens at Rest
Magic-link tokens and email OTP codes are stored hashed in the verification table (storeToken: "hashed", storeOTP: "hashed"), so a leaked database row can't be replayed. Both lifetimes are emitted explicitly (default 300 seconds, plugins.<name>.expiresIn to change), and the emails state the same value.
Rate Limiting
Quickback includes per-IP rate limiting by default to protect against brute-force attacks and abuse.
How It Works
Rate limits are applied per IP address, not globally:
┌─────────────────────────────────────────────────┐
│ IP: 192.168.1.1 │
│ Endpoint: /sign-in/email │
│ Limit: 10 requests per 60 seconds │
│ │
│ Request 1-10: ✅ Allowed │
│ Request 11: ❌ 429 Too Many Requests │
│ After 60s: Counter resets to 0 │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ IP: 192.168.1.2 (different IP) │
│ Endpoint: /sign-in/email │
│ Independent counter: Not affected by 1.1 │
│ Starts at 0 │
└─────────────────────────────────────────────────┘Default Configuration
auth: defineAuth("better-auth", {
rateLimit: {
enabled: true,
window: 60, // 60 second window
max: 100, // 100 requests per IP per window
storage: "database", // default on Cloudflare Workers — see below
customRules: {
// Merged ON TOP of the built-in per-route rules (same path → yours wins)
"/sign-in/email": { window: 60, max: 10 },
}
}
})Counter storage
Better Auth's own default keeps the counters in process memory. On
Cloudflare Workers that memory is per-isolate and per-colo, so counters reset
constantly and are bypassed by spreading requests across edges — an in-memory
limiter is decorative on the target runtime. The compiler therefore defaults
rateLimit.storage to "database" on Cloudflare: counters live in the auth
database (Better Auth adds a rateLimit table through the normal schema
generation, so a recompile produces the migration). On bun / node — one
long-lived process — the default is "memory".
storage | Where counters live | Use when |
|---|---|---|
"database" | Auth database (rateLimit table) | Cloudflare Workers (default there) |
"memory" | Process memory | Single long-lived process (bun / node default) |
The table exists whenever storage is "database", even with enabled: false,
so switching the limiter off or back on needs no migration.
Client IP
Better Auth reads the client IP from x-forwarded-for by default, which a
Worker doesn't receive. Without an IP every caller shares one bucket per path,
and a single client could use up a sign-in limit for everyone. On Cloudflare
the compiler therefore emits
advanced.ipAddress.ipAddressHeaders: ['cf-connecting-ip']. Cloudflare sets
that header at the edge, and clients can't override it. Set
advanced.ipAddress.ipAddressHeaders yourself to use different headers.
Built-in rules for every credential door
The compiler always emits a per-route rule for every credential endpoint
Better Auth mounts — not just password sign-in — so an OTP verify door (a
6-digit, 106-space code) is never left under the 100/min general
limit. Your customRules merge on top: declare a path to change its limit,
false to exempt it, or add paths for project plugins.
| Route(s) | Default | Reasoning |
|---|---|---|
/sign-in/email, /sign-in/username, /sign-in/social, /sign-in/anonymous | 10/min per IP | Credential stuffing |
/sign-up/email | 5/min per IP | Account-creation abuse |
/forget-password, /request-password-reset, /reset-password | 5/min per IP | Reset-token brute force, mail bombing |
/sign-in/email-otp, /email-otp/send-verification-otp, /email-otp/verify-email, /email-otp/check-verification-otp, /email-otp/reset-password, /email-otp/request-password-reset | 5/min per IP | OTP brute force + mail bombing |
/send-verification-email, /verify-email, /sign-in/magic-link, /magic-link/verify | 5–10/min per IP | Link/token brute force |
/phone-number/send-otp, /phone-number/verify, /phone-number/request-password-reset, /phone-number/reset-password | 5/min per IP | SMS OTP brute force + SMS cost |
/two-factor/send-otp, /two-factor/verify-otp, /two-factor/verify-totp, /two-factor/verify-backup-code | 5/min per IP | Second-factor brute force |
/device/init / /device/poll / /device/token | 5 / 30 / 10 per min per IP | Device-code enumeration, polling, token exchange |
/get-session, /token | exempt | Read-only, called on every page load; not credential doors. With database storage each limited call is a counter read and write in front of the session check. Declare the path in customRules to limit it again |
Everything else under /auth/v1 | max (100/min per IP) | General abuse prevention |
Set an edge rate-limit binding as well for defence in depth
(rateLimit) — the two layers are independent.
Why Per-IP?
✅ Per-IP (what Quickback uses):
- Prevents single attacker from brute-forcing
- Doesn't penalize legitimate users when one IP is malicious
- Standard for auth endpoints
❌ Global total (not used):
- Would allow distributed attacks to succeed
- One attacker with many IPs could exhaust the limit for everyone
- Not effective for security
Disabling Rate Limiting
rateLimit: { enabled: false }The compiler emits enabled: false explicitly. Better Auth would otherwise
fall back to enabled: isProduction. Disabling makes sense when many real
users share one IP, such as everyone on an event venue's Wi-Fi. It turns off
only the per-IP limiter. The per-key API key limit
stays on.
Bearer Tokens and Cookie Precedence
Quickback supports three bearer-token flavors alongside the standard browser session cookie:
| Credential | Used by | Resolved by |
|---|---|---|
| Session cookie | Browser SPAs (CMS, Account, your app) | Better Auth session lookup |
| Session-token bearer | API clients that grabbed set-auth-token | Better Auth bearer plugin |
| JWT bearer | API clients that mint at GET /auth/v1/token | In-worker JWT fast-path (no DB hit) |
| API-key bearer | Long-lived programmatic access | apiKey plugin / x-api-key header, or a prefixed key as Authorization: Bearer (never a query parameter) |
These match Better Auth's own framing of bearer tokens as an alternative to cookies, not a supplement — a request uses one or the other.
One transport per request
A request carries exactly one credential. Two or more of Cookie, Authorization, and x-api-key is a 400 AUTH_METHOD_CONFLICT — no precedence rules, no silent override:
Request Resolved as
────────────────────────────────────── ─────────────
Cookie only cookie session
Authorization: Bearer <jwt> JWT claims
Authorization: Bearer <session-token> bearer session
x-api-key: <api-key> API-key owner (narrowed by its scopes)
Any two of the above 400 AUTH_METHOD_CONFLICTThis closes a class of silent-impersonation bugs where an ambient session cookie (multi-account browser tabs, a malicious extension, a misconfigured proxy injecting Set-Cookie) would otherwise override an explicit credential. The decision is made before getSession() runs, so it applies to every code path in the auth middleware.
The api-key and JWT bearers also short-circuit before cookie resolution via dedicated fast-paths — they never touch the database for session lookup. Only session-token bearers reach getSession(), where Better Auth's bearer plugin resolves the Authorization header.
requireSignature: true is the secure default
Quickback configures the Better Auth bearer plugin with requireSignature: true by default. This means:
- The session token Better Auth emits via the
set-auth-tokenresponse header is signed server-side withBETTER_AUTH_SECRET. - Unsigned bearer tokens are rejected at the plugin layer before any session lookup happens.
- A session token leaked via a proxy access log, a browser dev-tools panel, or a misrouted
Refererheader cannot be replayed as a bearer token by an attacker — they would need the signing secret to forge a valid signature. - With
deviceAuthorizationenabled, the device-flow token endpoint (/device/token) returns its session token already signed, and the generatedPOST /cli/exchangeonly validates signed tokens. No generated route signs a token a caller hands it (see Device Authorization).
Better Auth's own default for this option is false. Quickback overrides it on the secure side. JWT bearers and agent-auth bearers are independently signed and unaffected by this setting.
Overriding the default
If a project genuinely needs unsigned bearers (e.g., a closed loopback service where signing is redundant), pass an explicit object:
auth: defineAuth("better-auth", {
plugins: {
bearer: { requireSignature: false },
},
})User-supplied options merge on top of the secure default, so any other bearer plugin option can be passed the same way.
Threat model and trade-offs
| Threat | Mitigation |
|---|---|
| Stolen session-token bearer replayed against the API | requireSignature: true blocks unsigned values |
| Raw session token (database read, backup, log) turned into a signed bearer by an endpoint that signs on request | /device/token signs at issue; /cli/exchange refuses unsigned tokens with 401 and never signs |
| Browser extension injects a victim's session cookie into an attacker's bearer-authed API call | Cookie stripped when Authorization is present |
| Multi-account user has cookies for two identities and uses bearer for the second | Bearer identity wins; no ambiguity in audit log |
| MCP / agent flow exposed to ambient browser cookies | /mcp and agent-auth routes are bearer-only (cookies dropped entirely, see Agent Auth) |
| Alg-confusion / forged JWT (alg=none, HS256, RS256) | Verifier accepts only EdDSA against the keyring's public JWKS; every other alg is rejected uniformly with 401 |
For a deeper walkthrough of the bearer flow itself, see the upstream Better Auth bearer plugin docs.
Cross-Domain Authentication
For applications spanning multiple subdomains (e.g., account.example.com, api.example.com, dashboard.example.com), Quickback provides dual-layer security.
The Problem
Browsers restrict cookies to exact domains by default. If your auth API is at api.example.com, cookies won't be sent to dashboard.example.com.
The Solution: Dual-Layer Protection
Quickback uses two complementary security layers:
1. Cookie Domain (Browser-Level)
auth: defineAuth("better-auth", {
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: 'example.com' // All *.example.com can access cookies
}
}
})Behavior: Allows ALL subdomains to access the cookie (browser limitation per RFC 6265)
- Cannot selectively allow only specific subdomains
- All-or-nothing: either exact domain only, or all subdomains
2. Trusted Origins (Application-Level)
auth: defineAuth("better-auth", {
trustedOrigins: [
'https://account.example.com',
'https://api.example.com',
'https://dashboard.example.com'
]
})Behavior: Better Auth validates request origins before processing auth operations
- Granular whitelist of allowed origins
- Blocks requests from untrusted subdomains even if they have the cookie
- Prevents CSRF and unauthorized auth operations
Security Model
┌─────────────────────────────────────────────────┐
│ Subdomain: evil.example.com │
│ Has Cookie: ✓ (via crossSubDomainCookies) │
│ In trustedOrigins: ✗ │
│ Result: REQUEST BLOCKED by Better Auth │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Subdomain: account.example.com │
│ Has Cookie: ✓ (via crossSubDomainCookies) │
│ In trustedOrigins: ✓ │
│ Result: REQUEST ALLOWED │
└─────────────────────────────────────────────────┘Full Cross-Domain Configuration
import { defineAuth, defineConfig, defineDatabase, defineRuntime } from "@quickback/compiler";
export default defineConfig({
name: "my-saas",
providers: {
runtime: defineRuntime("cloudflare"),
database: defineDatabase("cloudflare-d1"),
auth: defineAuth("better-auth", {
emailAndPassword: { enabled: true },
plugins: ["organization"],
// Trusted origins (granular whitelist)
trustedOrigins: [
"https://account.example.com",
"https://api.example.com",
"https://dashboard.example.com",
"http://localhost:3000", // Development
],
// Cookie configuration
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: "example.com",
},
defaultCookieAttributes: {
secure: true,
sameSite: "none", // Required for cross-domain
httpOnly: true,
}
}
})
},
bindings: {
vars: {
ACCOUNT_URL: "https://account.example.com",
BETTER_AUTH_URL: "https://api.example.com",
},
},
})Important: When using sameSite: "none", you must also set secure: true. This is required by browsers.
Environment Variables
Set these in your Cloudflare Workers environment or .env file:
Required
# Better Auth secret (minimum 32 characters)
BETTER_AUTH_SECRET=your-secret-key-min-32-chars
# Base URL for Better Auth endpoints
BETTER_AUTH_URL=https://api.example.comOptional (Cross-Domain)
# Enable cross-subdomain cookies
CROSS_SUBDOMAIN_COOKIES=true
COOKIE_DOMAIN=example.com
# Trusted origins (comma-separated)
TRUSTED_ORIGINS=https://account.example.com,https://api.example.com
# CORS allowed origins (typically matches trustedOrigins)
ALLOWED_ORIGINS=https://account.example.com,https://api.example.comSetting Secrets with Wrangler
For sensitive values like BETTER_AUTH_SECRET:
wrangler secret put BETTER_AUTH_SECRET
# Paste your secret when promptedFor non-secret vars, add to wrangler.toml:
[vars]
BETTER_AUTH_URL = "https://api.example.com"
CROSS_SUBDOMAIN_COOKIES = "false"Security Best Practices
Single Domain (Recommended)
For most applications, use exact domain cookies:
auth: defineAuth("better-auth", {
trustedOrigins: ["https://app.example.com"],
advanced: {
crossSubDomainCookies: {
enabled: false // Default: exact domain only
},
defaultCookieAttributes: {
sameSite: 'lax',
secure: true,
httpOnly: true,
}
}
})Benefits:
- Maximum security (smallest cookie scope)
- No cross-subdomain attack surface
- Simpler configuration
Multi-Subdomain (When Needed)
Only enable cross-subdomain cookies if you need SSO across multiple subdomains:
auth: defineAuth("better-auth", {
trustedOrigins: [
"https://account.example.com",
"https://dashboard.example.com",
],
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: "example.com",
},
defaultCookieAttributes: {
sameSite: 'none', // Required for cross-domain
secure: true,
httpOnly: true,
}
}
})Security requirements:
- Trust all subdomains equally (any compromised subdomain can steal cookies)
- Use separate root domains for untrusted services
- Always set
trustedOriginsfor granular control
CORS vs Trusted Origins
These serve different purposes and should typically match:
| Setting | Purpose |
|---|---|
ALLOWED_ORIGINS (CORS) | Controls which origins can make credentialed requests |
trustedOrigins (Better Auth) | Validates origins for auth operations + prevents open redirects |
Example:
bindings: {
vars: {
ALLOWED_ORIGINS: "https://account.example.com,https://api.example.com"
}
},
providers: {
auth: defineAuth("better-auth", {
trustedOrigins: [
"https://account.example.com",
"https://api.example.com"
]
})
}Checklist
Before deploying to production:
-
BETTER_AUTH_SECRETis set (minimum 32 random characters) -
secure: trueis enabled (HTTPS only) -
httpOnly: trueis enabled (XSS protection) -
sameSite: 'lax'or'strict'unless cross-domain required -
trustedOriginsincludes only your domains -
ALLOWED_ORIGINSmatchestrustedOrigins - Rate limiting is enabled (
rateLimit.enabled: true) - Custom rate limits set for sensitive endpoints
Deployment Considerations
Behind Proxy/Load Balancer
If your app is behind a proxy, ensure IP address headers are trusted:
auth: defineAuth("better-auth", {
autoDetectIpAddress: true, // Uses X-Forwarded-For or CF-Connecting-IP
})Cloudflare Workers: IP automatically available via CF-Connecting-IP header
Other proxies: Configure to trust X-Forwarded-For or X-Real-IP
Multiple Environments
Use environment variables to configure different settings per environment:
auth: defineAuth("better-auth", {
trustedOrigins: process.env.TRUSTED_ORIGINS?.split(',') || [],
advanced: {
crossSubDomainCookies: {
enabled: process.env.CROSS_SUBDOMAIN_COOKIES === 'true',
domain: process.env.COOKIE_DOMAIN,
},
defaultCookieAttributes: {
sameSite: process.env.COOKIE_SAMESITE || 'lax',
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
}
}
})Development (.dev.vars):
TRUSTED_ORIGINS=http://localhost:3000,http://localhost:5173
CROSS_SUBDOMAIN_COOKIES=false
COOKIE_SAMESITE=laxProduction (Wrangler secrets):
TRUSTED_ORIGINS=https://account.example.com,https://api.example.com
CROSS_SUBDOMAIN_COOKIES=true
COOKIE_DOMAIN=example.com
COOKIE_SAMESITE=none