Base Path /api/auth
GET

Get CSRF Token

â–ļ
GET /api/auth/csrf-token

Issues a CSRF token using the Double Submit Cookie pattern. Must be called before any state-changing request (POST /register, POST /login, etc.) from a browser. Returns the token in the response body and sets a csrf_token cookie. The client must echo the token back on every mutating request via the X-CSRF-Token header.

â„šī¸ Rate limited: 30 requests / hour / IP.
CSRF not required on this endpoint — it issues the token.
Response
200 — Token issued
{
  "success": true,
  "data": {
    "csrfToken": "a3f9c2e1b7d04e8fa1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4"
  }
}
// Cookie set: csrf_token=a3f9...  (non-httpOnly, SameSite=Strict)
POST

Register

â–ļ
POST /api/auth/register

Creates a new user account and sends a 6-digit OTP to the provided email. The account is not activated until email is verified. Auth cookies are not set at this stage.

🛡 Rate limit: 5 requests / hour / IP  |  CSRF: Required — send X-CSRF-Token header (skipped if Authorization header is present or no Origin header).
Request Body
FieldTypeRequiredDescription
usernamestringrequired3–20 chars, letters/numbers/underscore only
emailstringrequiredValid email, max 255 chars
passwordstringrequired8–64 characters
confirmPasswordstringrequiredMust match password
Request Example
JSON
{
  "username": "johndoe",
  "email": "john@example.com",
  "password": "MySecurePass123",
  "confirmPassword": "MySecurePass123"
}
Responses
201 Created409 Conflict400 Validation
201 — Success
{
  "success": true,
  "message": "Account created. Please check your email for a verification code.",
  "data": {
    "requiresVerification": true,
    "email": "john@example.com"
  }
}
409 — Email already exists
{
  "success": false,
  "errorCode": "USER_ALREADY_EXISTS",
  "message": "An account with this email already exists."
}
POST

Login

â–ļ
POST /api/auth/login

Authenticates a user with email/username and password. Sets httpOnly cookies on success. If email is not verified, sends a fresh OTP and returns 403 EMAIL_NOT_VERIFIED.

🛡 Rate limit: 10 requests / 15 min / IP  |  CSRF: Required — send X-CSRF-Token header (skipped if Authorization header is present or no Origin header).
Request Body
FieldTypeRequiredDescription
usernameOrEmailstringrequiredEmail address or username
passwordstringrequiredAccount password (8–64 chars)
Request Example
JSON
{
  "usernameOrEmail": "john@example.com",
  "password": "MySecurePass123"
}
Responses
200 OK401 Wrong password403 Not verified
200 — Success
{
  "success": true,
  "message": "Logged in successfully.",
  "data": {
    "user": {
      "id": "usr_abc123",
      "email": "john@example.com",
      "username": "johndoe",
      "role": "user",
      "status": "active",
      "isSubscribed": false,
      "subscriptionStatus": "incomplete"
    }
  }
}
// Cookies set: access_token (15m), refresh_token (30d)
âš ī¸ If email is not verified, the server sends a fresh OTP and returns 403 with "errorCode": "EMAIL_NOT_VERIFIED" and "data": { "email": "..." }.
POST

Verify Email

â–ļ
POST /api/auth/verify-email

Verifies the 6-digit OTP sent during registration or login (for unverified accounts). On success, sets auth cookies and logs the user in. Max 3 attempts before OTP is invalidated.

🛡 Rate limit: 10 requests / 15 min / IP  |  CSRF: Required — send X-CSRF-Token header.
Request Body
FieldTypeRequiredDescription
emailstringrequiredEmail address linked to the OTP
otpstringrequiredExactly 6 numeric digits
Request Example
JSON
{
  "email": "john@example.com",
  "otp": "482931"
}
Responses
200 OK400 Invalid OTP400 Expired
200 — Verified & logged in
{
  "success": true,
  "message": "Email verified successfully. You are now logged in.",
  "data": {
    "user": {
      "id": "usr_abc123",
      "email": "john@example.com",
      "username": "johndoe",
      "role": "user"
    }
  }
}
// Cookies set: access_token, refresh_token
400 — Invalid code
{
  "success": false,
  "errorCode": "OTP_INVALID",
  "message": "Incorrect code. 2 attempts remaining."
}
POST

Resend OTP

â–ļ
POST /api/auth/resend-otp

Resends the email verification OTP. Returns a generic success message regardless of whether the email exists (prevents email enumeration).

🛡 Rate limit: 5 requests / 15 min / IP (IP-level gate) + service-level 60-second per-email cooldown  |  CSRF: Required — send X-CSRF-Token header.
Request Body
FieldTypeRequiredDescription
emailstringrequiredEmail address to resend OTP to
Response
200 — Always returned
{
  "success": true,
  "message": "If an account with that email exists, a new code has been sent."
}
400 — Too soon
{
  "success": false,
  "errorCode": "OTP_RESEND_TOO_SOON",
  "message": "Please wait 45 seconds before requesting a new code."
}
POST

Refresh Access Token

â–ļ
POST /api/auth/refresh

Issues a new access token using a valid refresh token. The refresh token can be provided via the request body, Authorization: Bearer header, X-Refresh-Token header, or the refresh_token cookie.

🛡 Rate limit: 30 requests / 15 min / IP  |  CSRF: Not required — the refresh token itself proves identity; no cookie-based CSRF vector exists here.
Request Body (optional — cookie is preferred for browsers)
FieldTypeRequiredDescription
refreshTokenstringoptionalRefresh JWT (if not using cookie)
Response
200 — New access token
{
  "success": true,
  "message": "Token refreshed successfully.",
  "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  }
}
// Also sets updated access_token cookie
401 — Invalid refresh token
{
  "success": false,
  "errorCode": "INVALID_REFRESH_TOKEN",
  "message": "Refresh token is invalid or expired. Please log in again."
}
POST

Logout

🔒 Auth Required â–ļ
POST /api/auth/logout

Clears the access_token and refresh_token cookies. Requires a valid access token.

🛡 Rate limit: None  |  CSRF: Required — send X-CSRF-Token header.
Response
200 — Logged out
{
  "success": true,
  "message": "Logged out successfully."
}
GET

Get Current User

🔒 Auth Required â–ļ
GET /api/auth/me

Returns the full profile of the authenticated user including subscription details and preferences.

Response
200 — Current user
{
  "success": true,
  "data": {
    "user": {
      "id": "usr_abc123",
      "username": "johndoe",
      "email": "john@example.com",
      "role": "user",
      "avatar": "https://api.dicebear.com/7.x/initials/svg?seed=johndoe",
      "language": "en",
      "timezone": "UTC",
      "preferences": { "default_results_per_page": 25 },
      "status": "active",
      "isSubscribed": true,
      "subscriptionStatus": "active",
      "haveTrial": false,
      "trialEnd": null,
      "lastActivity": "2026-04-22T10:00:00.000Z",
      "createdAt": "2026-01-15T08:00:00.000Z",
      "subscription": {
        "id": "sub_xyz",
        "status": "active",
        "periodEnd": "2026-05-22T00:00:00.000Z",
        "cancelAtPeriodEnd": false,
        "plan": {
          "id": "plan_basic",
          "name": "Basic",
          "settings": { "max_tools": 5 }
        }
      }
    }
  }
}
GET

Google OAuth

â–ļ
GET /api/auth/google

Redirects the browser to Google's OAuth consent screen. On successful authorization Google redirects to /api/auth/google/callback, which sets auth cookies and redirects to {DASH_ORIGIN}/dashboard.

🛡 Rate limit: 10 requests / 5 min / IP (shared between /google and /google/callback)  |  CSRF: Not required — GET method, no state change on this endpoint.
Flow
OAuth Flow
1. Browser navigates to → GET /api/auth/google
2. Server generates a random state UUID, stores it in KV (TTL 5 min)
3. Server redirects    → https://accounts.google.com/o/oauth2/v2/auth?...&state=...
4. User consents       → Google redirects back to:
                         GET /api/auth/google/callback?code=...&state=...
5. Server validates state against KV (CSRF check) and deletes it (single-use)
6. Server exchanges code for Google access token, fetches user profile
7. New user  → auto-registered with emailVerified: true (Google already verified it)
   Existing  → avatar updated, lastActivity stamped
8. Server sets access_token + refresh_token cookies
9. Server redirects    → {DASH_ORIGIN}/dashboard
Callback Query Params (handled internally by Google)
ParamTypeDescription
codestringAuthorization code — exchanged for tokens server-side
statestringCSRF state UUID — validated against KV before proceeding
â„šī¸ New users are auto-registered with a random unusable password and emailVerified: true — no OTP step needed since Google has already verified the email.
Existing users are matched by email; avatar and lastActivity are updated.
Suspended / banned accounts receive 401 Unauthorized.
âš ī¸ State CSRF protection requires KV. If the CACHE KV namespace is not bound (e.g. local dev without KV), state validation is skipped. In production KV must be bound or the flow is vulnerable to CSRF. Configure it in wrangler.toml.
POST

Forgot Password

â–ļ
POST /api/auth/forgot-password

Sends a password reset link to the provided email address. The link contains a cryptographically secure 256-bit token and expires in 1 hour. The response is always identical whether the email is registered or not — this prevents email enumeration attacks.

🛡 Rate limit: 3 requests / hour / IP (tight — each request triggers an outbound email)  |  CSRF: Required — send X-CSRF-Token header.
Security design
  • Email not found → same 200 response (prevents enumeration)
  • Any existing reset token for the user is deleted before issuing a new one (one active token per user)
  • Only the SHA-256 hash of the raw token is stored in the database — the raw token is only ever in the email
  • 1-hour TTL enforced at both DB (expiresAt) and service layer
  • Reset URL format: {APP_ORIGIN}/reset-password?token=<rawToken>
Request Body
FieldTypeRequiredDescription
emailstringrequiredEmail address of the account to reset
Request Example
JSON
{
  "email": "john@example.com"
}
Response
200 — Always returned (anti-enumeration)
{
  "success": true,
  "message": "If an account with that email exists, you will receive a password reset link shortly."
}
429 — Rate limit exceeded
{
  "success": false,
  "errorCode": "RATE_LIMITED",
  "message": "Too many requests. Please wait and try again."
}
Email sent to user
Reset link format
// The email contains a button/link pointing to:
{APP_ORIGIN}/reset-password?token=<256-bit-hex-token>

// Example:
https://app.example.com/reset-password?token=a1b2c3d4e5f6...
POST

Reset Password

â–ļ
POST /api/auth/reset-password

Consumes the reset token from the email link and updates the user's password. The token is single-use — it is deleted immediately on success. After resetting, the user must log in normally with the new password.

🛡 Rate limit: 5 requests / 15 min / IP  |  CSRF: Required — send X-CSRF-Token header.
Security design
  • Incoming token is SHA-256 hashed before lookup — raw token is never logged or compared directly
  • Attempts capped at 5 (defence-in-depth) — token is deleted after 5 failed submissions
  • Token deleted on expiry, max-attempts, and success (single-use)
  • New password is hashed with PBKDF2 before storage
  • Auth cookies are not set — user must log in after reset
Request Body
FieldTypeRequiredDescription
tokenstringrequiredRaw token from the reset URL query parameter
passwordstringrequiredNew password, 8–64 characters
confirmPasswordstringrequiredMust exactly match password
Request Example
JSON
{
  "token": "a1b2c3d4e5f6...",
  "password": "NewSecurePass456",
  "confirmPassword": "NewSecurePass456"
}
Responses
200 OK400 Invalid token400 Expired400 Max attempts
200 — Password reset successfully
{
  "success": true,
  "message": "Your password has been reset. You can now sign in with your new password."
}
// No auth cookies set — user must POST /login with new password
400 — Token invalid or already used
{
  "success": false,
  "errorCode": "RESET_TOKEN_INVALID",
  "message": "This password reset link is invalid or has already been used."
}
400 — Token expired (1 hour TTL)
{
  "success": false,
  "errorCode": "RESET_TOKEN_EXPIRED",
  "message": "This password reset link has expired. Please request a new one."
}
400 — Max 5 attempts reached
{
  "success": false,
  "errorCode": "RESET_TOKEN_MAX_ATTEMPTS",
  "message": "This reset link has been invalidated after too many attempts. Please request a new one."
}
Frontend integration
Reading the token from URL and submitting
// Read token from URL query param
const params = new URLSearchParams(window.location.search);
const token = params.get("token"); // the raw 256-bit hex token

// Submit new password
const res = await fetch("/api/auth/reset-password", {
  method: "POST",
  credentials: "include",
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-Token": csrfToken,
  },
  body: JSON.stringify({
    token,
    password: "NewSecurePass456",
    confirmPassword: "NewSecurePass456",
  }),
});

// On success → redirect to /login
if (res.ok) window.location.href = "/login";

Multi-step Registration

An email-first registration flow. No account is created until all three steps are complete. The OTP is sent before the user picks a username or password — so only real emails receive the code and accounts are always verified on creation.

â„šī¸ Flow summary:
Step 1 /register/init — send email → OTP delivered.
Step 2 /register/verify — submit OTP → receive a short-lived registrationToken.
Step 3 /register/complete — submit token + username + password → account created, auth cookies set.
POST

Register — Init

â–ļ
POST /api/auth/register/init

Step 1. Accepts an email address, checks it is not already registered, and sends a 6-digit OTP. No user account is created at this stage. A 60-second per-email cooldown prevents OTP flooding (distinct from the IP-level rate limit).

🛡 Rate limit: 5 requests / 15 min / IP  |  CSRF: Required — send X-CSRF-Token header.
Request Body
FieldTypeRequiredDescription
emailstringrequiredValid email address, max 255 chars
Request Example
JSON
{
  "email": "john@example.com"
}
Responses
200 OK409 Already exists400 Too soon
200 — OTP sent
{
  "success": true,
  "message": "Verification code sent to john@example.com. It expires in 10 minutes."
}
409 — Email already registered
{
  "success": false,
  "errorCode": "USER_ALREADY_EXISTS",
  "message": "An account with this email already exists."
}
400 — Cooldown active
{
  "success": false,
  "errorCode": "OTP_RESEND_TOO_SOON",
  "message": "Please wait 45 seconds before requesting a new code."
}
POST

Register — Verify OTP

â–ļ
POST /api/auth/register/verify

Step 2. Accepts the email and the 6-digit OTP. On success, returns a short-lived registrationToken (JWT, 15 min, audience "registration") that unlocks Step 3. Invalidated after 3 failed attempts.

🛡 Rate limit: 10 requests / 15 min / IP  |  CSRF: Required — send X-CSRF-Token header.
Request Body
FieldTypeRequiredDescription
emailstringrequiredSame email used in Step 1
otpstringrequired6-digit numeric code from the email
Request Example
JSON
{
  "email": "john@example.com",
  "otp": "483910"
}
Responses
200 OK400 Invalid OTP400 Expired
200 — OTP verified
{
  "success": true,
  "data": {
    "registrationToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  }
}
// Store this token and pass it to Step 3. Expires in 15 minutes.
400 — Wrong code
{
  "success": false,
  "errorCode": "OTP_INVALID",
  "message": "Incorrect code. 2 attempts remaining."
}
400 — OTP expired
{
  "success": false,
  "errorCode": "OTP_EXPIRED",
  "message": "This code has expired. Please request a new one."
}
POST

Register — Complete

â–ļ
POST /api/auth/register/complete

Step 3. Accepts the registrationToken from Step 2 plus the user's desired username, password, and optional name. Creates the account with emailVerified: true and sets auth cookies — the user is immediately logged in. The token cannot be reused (replay protection: email uniqueness check catches second use).

🛡 Rate limit: 5 requests / hour / IP  |  CSRF: Required — send X-CSRF-Token header.
Request Body
FieldTypeRequiredDescription
registrationTokenstringrequiredJWT returned by Step 2, valid 15 min
usernamestringrequired3–20 chars, letters/numbers/underscore only
passwordstringrequired8–64 characters
confirmPasswordstringrequiredMust exactly match password
namestringoptionalDisplay name, max 100 chars
Request Example
JSON
{
  "registrationToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "username": "johndoe",
  "password": "MySecurePass123",
  "confirmPassword": "MySecurePass123",
  "name": "John Doe"
}
Responses
201 Created400 Invalid token409 Username taken
201 — Account created & logged in
{
  "success": true,
  "message": "Account created successfully. You are now logged in.",
  "data": {
    "user": {
      "id": "usr_abc123",
      "email": "john@example.com",
      "username": "johndoe",
      "role": "user"
    }
  }
}
// Cookies set: access_token (15m), refresh_token (30d)
400 — Token invalid or expired
{
  "success": false,
  "errorCode": "REGISTRATION_TOKEN_INVALID",
  "message": "Registration token is invalid or has expired. Please start again."
}
409 — Username or email already taken
{
  "success": false,
  "errorCode": "USER_ALREADY_EXISTS",
  "message": "Username is already taken."
}
POST

Register — Resend OTP

â–ļ
POST /api/auth/register/resend

Resends the OTP to an email that is still in the pre-registration flow (before account creation). Enforces the same 60-second per-email cooldown as /register/init. Use this when the user didn't receive the code or it expired before Step 2.

🛡 Rate limit: 5 requests / 15 min / IP (IP gate) + 60-second per-email cooldown in service  |  CSRF: Required — send X-CSRF-Token header.
Request Body
FieldTypeRequiredDescription
emailstringrequiredSame email used in /register/init
Responses
200 OK400 Too soon
200 — OTP resent
{
  "success": true,
  "message": "Verification code sent to john@example.com. It expires in 10 minutes."
}
400 — Cooldown active
{
  "success": false,
  "errorCode": "OTP_RESEND_TOO_SOON",
  "message": "Please wait 42 seconds before requesting a new code."
}

Rate Limits

All limits are fixed-window, keyed by client IP (CF-Connecting-IP). Headers X-RateLimit-Limit and X-RateLimit-Remaining are set on every response. On breach: 429 + Retry-After header.

RouteLimitWindowReason
GET /csrf-token30 req1 hourToken issuance — low-risk read
POST /register5 req1 hourSlow down bot account creation
POST /login10 req15 minBrute-force password protection
POST /verify-email10 req15 minLimit OTP guessing attempts
POST /resend-otp5 req15 minIP gate + 60s per-email cooldown in service
POST /refresh30 req15 minTokens expire every 15 min — headroom for legit clients
GET /google10 req5 minOAuth flow spam prevention (shared with callback)
GET /google/callback10 req5 minShared with /google redirect
GET /me——No limit
POST /logout——No limit
POST /register/init5 req15 minPrevents email bombing
POST /register/verify10 req15 minOTP brute-force protection
POST /register/complete5 req1 hourLow frequency expected; matches /register
POST /register/resend5 req15 minIP gate + 60s per-email service cooldown
POST /forgot-password3 req1 hourTight — each request triggers an outbound email
POST /reset-password5 req15 minToken submission; service layer also caps at 5 attempts
429 — Rate limit exceeded
{
  "success": false,
  "errorCode": "RATE_LIMITED",
  "message": "Too many requests. Please wait and try again."
}
// Response headers:
// X-RateLimit-Limit: 10
// X-RateLimit-Remaining: 0
// Retry-After: 900

CSRF Protection

Uses the Double Submit Cookie pattern. The server generates a random 64-char hex token, sets it as a csrf_token cookie (non-httpOnly), and returns it in the response body. The client must echo it back via the X-CSRF-Token header on every mutating request.

RouteCSRF RequiredWhy
GET /csrf-tokenNoIssues the token — GET method, no state change
POST /registerYesCreates an account
POST /loginYesSets auth cookies
POST /verify-emailYesSets auth cookies on success
POST /resend-otpYesTriggers email send
POST /refreshNoRefresh token itself is proof of identity — no cookie CSRF vector
GET /meNoGET method, read-only
POST /logoutYesClears auth cookies
GET /googleNoGET method
GET /google/callbackNoGET method
POST /register/initYesTriggers email send
POST /register/verifyYesConsumes OTP, issues registration token
POST /register/completeYesCreates account, sets auth cookies
POST /register/resendYesTriggers email send
POST /forgot-passwordYesTriggers email send, state-mutating
POST /reset-passwordYesMutates user password, state-mutating
â„šī¸ CSRF is automatically skipped when: (1) the request carries an Authorization header — Bearer token clients don't use cookies so no CSRF vector; (2) no Origin header is present — server-to-server calls (curl, Workers) are not browser requests.
CSRF + Cookie flow — what frontend must do (fetch)
// ─── Step 1: Fetch the CSRF token ────────────────────────────────────────────
// credentials: "include" is MANDATORY — without it the browser will not store
// the csrf_token cookie that the server sets in the response.

const res = await fetch("https://api.example.workers.dev/api/auth/csrf-token", {
  method: "GET",
  credentials: "include",   // ← stores the csrf_token cookie from Set-Cookie header
});
const { data } = await res.json();
const csrfToken = data.csrfToken; // store in memory — NOT localStorage

// What happens at this point:
//   Server sets:   Set-Cookie: csrf_token=a3f9c2...; SameSite=None; Secure
//   Browser stores: csrf_token cookie under api.example.workers.dev domain
//   Your code holds: csrfToken variable = "a3f9c2..."

// ─── Step 2: Call any mutating route (login, register, etc.) ─────────────────
// credentials: "include" is MANDATORY again — without it the browser will NOT
// send the csrf_token cookie back to the server on this cross-origin request.

const loginRes = await fetch("https://api.example.workers.dev/api/auth/login", {
  method: "POST",
  credentials: "include",       // ← sends the csrf_token cookie automatically
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-Token": csrfToken,  // ← you send the token value in the header
  },
  body: JSON.stringify({ usernameOrEmail: "...", password: "..." }),
});

// What the server checks:
//   cookie  csrf_token   = "a3f9c2..."   (sent by browser automatically)
//   header  X-CSRF-Token = "a3f9c2..."   (sent by your code above)
//   match → ✅ request passes
CSRF + Cookie flow — axios equivalent
// For axios use withCredentials instead of credentials: "include"

// Step 1 — get token
const { data } = await axios.get("/api/auth/csrf-token", {
  withCredentials: true,   // ← stores csrf_token cookie
});
const csrfToken = data.data.csrfToken;

// Step 2 — login
await axios.post("/api/auth/login", { usernameOrEmail: "...", password: "..." }, {
  withCredentials: true,       // ← sends csrf_token cookie
  headers: { "X-CSRF-Token": csrfToken },
});
âš ī¸ Most common mistake: missing credentials: "include" (or withCredentials: true).
— Without it on the csrf-token request → browser discards the Set-Cookie header → cookie is never stored.
— Without it on the login/register request → browser does not attach the cookie → server sees no cookie → CSRF_DETECTED.
403 — CSRF errors and what caused them
// Cookie was never stored (missing credentials: "include" on csrf-token fetch)
{ "errorCode": "CSRF_DETECTED", "message": "CSRF token missing. Call GET /api/auth/csrf-token first." }

// Cookie stored but not sent (missing credentials: "include" on login fetch)
{ "errorCode": "CSRF_DETECTED", "message": "CSRF token missing. Call GET /api/auth/csrf-token first." }

// Cookie sent but X-CSRF-Token header missing or wrong value
{ "errorCode": "CSRF_DETECTED", "message": "CSRF token invalid. Token in header does not match cookie." }

Cookies & Cross-Origin Storage

Where cookies are stored and why
// The browser stores cookies per domain — under the domain that SET them.
// The csrf_token cookie is set by the backend, so it lives on the backend domain.
// This is correct. The browser will send it back to that backend domain automatically.

Frontend origin:  killaseason-7yl.pages.dev
Backend origin:   ai-tools-backend.ericfulio39.workers.dev

GET /api/auth/csrf-token
  └─ Response: Set-Cookie: csrf_token=ee47...; Domain=ai-tools-backend.ericfulio39.workers.dev
                                                SameSite=None; Secure; HttpOnly=false
  └─ Browser stores csrf_token under: ai-tools-backend.ericfulio39.workers.dev  ← correct
  └─ NOT stored under killaseason domain                                          ← also correct

POST /api/auth/login  (from killaseason frontend → to backend)
  └─ Browser sees: request is going TO ai-tools-backend.ericfulio39.workers.dev
  └─ Browser finds: csrf_token cookie belongs to that domain
  └─ SameSite=None: cookie is allowed on cross-site requests
  └─ credentials: "include": tells browser to actually attach it
  └─ Result: Cookie: csrf_token=ee47...  is sent ✅
SameSite values and why None is required here
// SameSite controls when the browser attaches a cookie to outgoing requests.

SameSite=Strict  → cookie sent ONLY when request comes from the same site as cookie domain
                   killaseason → backend = cross-site → cookie BLOCKED ❌

SameSite=Lax     → cookie sent on same-site + top-level navigation GET requests
                   POST cross-origin = cookie BLOCKED ❌

SameSite=None    → cookie sent on ALL requests including cross-site, requires Secure=true
                   killaseason → backend = cross-site → cookie SENT ✅

// auth cookies (access_token, refresh_token) use SameSite=Lax because they are
// used by the dashboard on the same .workers.dev domain as the backend.
// csrf_token uses SameSite=None because the frontend (pages.dev) is cross-site.
Auth cookies — access_token & refresh_token
// Set on: POST /login, POST /verify-email, POST /refresh, GET /google/callback, POST /register/complete
// Cleared on: POST /logout

access_token   httpOnly=true  Secure=true  SameSite=Lax  MaxAge=15min
refresh_token  httpOnly=true  Secure=true  SameSite=Lax  MaxAge=30days

// httpOnly=true → JS cannot read these. Browser sends them automatically.
// SameSite=Lax  → works for dashboard (same .workers.dev domain as backend).
//                 Does NOT work for killaseason (different domain).
//                 For killaseason: access_token and refresh_token are also
//                 returned in the response body so the frontend can store and
//                 send them via Authorization header instead of cookie.

// csrf_token cookie:
csrf_token     httpOnly=false  Secure=true  SameSite=None  MaxAge=1hr
// httpOnly=false → JS must be able to read it (that's what makes Double Submit work).