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 the frontend 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 redirects    → https://accounts.google.com/o/oauth2/v2/auth?...
3. User consents       → Google redirects back to:
                         GET /api/auth/google/callback?code=...&state=...
4. Server exchanges code for tokens, upserts user, sets cookies
5. Server redirects    → {APP_ORIGIN}/dashboard
Callback Query Params (handled internally)
ParamTypeDescription
codestringAuthorization code from Google
statestringCSRF state parameter (optional)
â„šī¸ New Google users are auto-registered. Existing accounts are matched by email and their avatar is updated. Suspended/banned accounts receive 401.

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
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
â„šī¸ 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).