Quickback Docs

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.

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:

AttributeValuePurpose
sameSitelaxBlocks cookies on cross-site POST requests (CSRF protection)
securetrueCookies only sent over HTTPS (prevents interception)
httpOnlytrueJavaScript 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 (requires secure: 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:

SettingQuickback defaultBetter Auth defaultOverride
session.freshAge15 minutes24 hourssession.freshAgeInHours
emailAndPassword.revokeSessionsOnPasswordResettruefalseemailAndPassword.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 admittedWhy
Accounts created by an admin (POST /auth/v1/admin/create-user)Operators provision users directly
An email with a pending, unexpired organization invitationInvitees must be able to sign up
The deploy owner's first Cloudflare loginThe 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".

storageWhere counters liveUse when
"database"Auth database (rateLimit table)Cloudflare Workers (default there)
"memory"Process memorySingle 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)DefaultReasoning
/sign-in/email, /sign-in/username, /sign-in/social, /sign-in/anonymous10/min per IPCredential stuffing
/sign-up/email5/min per IPAccount-creation abuse
/forget-password, /request-password-reset, /reset-password5/min per IPReset-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-reset5/min per IPOTP brute force + mail bombing
/send-verification-email, /verify-email, /sign-in/magic-link, /magic-link/verify5–10/min per IPLink/token brute force
/phone-number/send-otp, /phone-number/verify, /phone-number/request-password-reset, /phone-number/reset-password5/min per IPSMS OTP brute force + SMS cost
/two-factor/send-otp, /two-factor/verify-otp, /two-factor/verify-totp, /two-factor/verify-backup-code5/min per IPSecond-factor brute force
/device/init / /device/poll / /device/token5 / 30 / 10 per min per IPDevice-code enumeration, polling, token exchange
/get-session, /tokenexemptRead-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/v1max (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.


Quickback supports three bearer-token flavors alongside the standard browser session cookie:

CredentialUsed byResolved by
Session cookieBrowser SPAs (CMS, Account, your app)Better Auth session lookup
Session-token bearerAPI clients that grabbed set-auth-tokenBetter Auth bearer plugin
JWT bearerAPI clients that mint at GET /auth/v1/tokenIn-worker JWT fast-path (no DB hit)
API-key bearerLong-lived programmatic accessapiKey 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_CONFLICT

This 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-token response header is signed server-side with BETTER_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 Referer header cannot be replayed as a bearer token by an attacker — they would need the signing secret to forge a valid signature.
  • With deviceAuthorization enabled, the device-flow token endpoint (/device/token) returns its session token already signed, and the generated POST /cli/exchange only 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

ThreatMitigation
Stolen session-token bearer replayed against the APIrequireSignature: 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 callCookie stripped when Authorization is present
Multi-account user has cookies for two identities and uses bearer for the secondBearer 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:

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.com

Optional (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.com

Setting Secrets with Wrangler

For sensitive values like BETTER_AUTH_SECRET:

wrangler secret put BETTER_AUTH_SECRET
# Paste your secret when prompted

For non-secret vars, add to wrangler.toml:

[vars]
BETTER_AUTH_URL = "https://api.example.com"
CROSS_SUBDOMAIN_COOKIES = "false"

Security Best Practices

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 trustedOrigins for granular control

CORS vs Trusted Origins

These serve different purposes and should typically match:

SettingPurpose
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_SECRET is set (minimum 32 random characters)
  • secure: true is enabled (HTTPS only)
  • httpOnly: true is enabled (XSS protection)
  • sameSite: 'lax' or 'strict' unless cross-domain required
  • trustedOrigins includes only your domains
  • ALLOWED_ORIGINS matches trustedOrigins
  • 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=lax

Production (Wrangler secrets):

TRUSTED_ORIGINS=https://account.example.com,https://api.example.com
CROSS_SUBDOMAIN_COOKIES=true
COOKIE_DOMAIN=example.com
COOKIE_SAMESITE=none

Further Reading

On this page