đ Authentication
Handles user registration, email OTP verification, login, token refresh, logout, and Google OAuth. Access tokens expire after 15 minutes; refresh tokens after 30 days.
Get 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.
CSRF not required on this endpoint â it issues the token.
{
"success": true,
"data": {
"csrfToken": "a3f9c2e1b7d04e8fa1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4"
}
}
// Cookie set: csrf_token=a3f9... (non-httpOnly, SameSite=Strict)
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.
X-CSRF-Token header (skipped if Authorization header is present or no Origin header).
| Field | Type | Required | Description |
|---|---|---|---|
| username | string | required | 3â20 chars, letters/numbers/underscore only |
| string | required | Valid email, max 255 chars | |
| password | string | required | 8â64 characters |
| confirmPassword | string | required | Must match password |
{
"username": "johndoe",
"email": "john@example.com",
"password": "MySecurePass123",
"confirmPassword": "MySecurePass123"
}
{
"success": true,
"message": "Account created. Please check your email for a verification code.",
"data": {
"requiresVerification": true,
"email": "john@example.com"
}
}
{
"success": false,
"errorCode": "USER_ALREADY_EXISTS",
"message": "An account with this email already exists."
}
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.
X-CSRF-Token header (skipped if Authorization header is present or no Origin header).
| Field | Type | Required | Description |
|---|---|---|---|
| usernameOrEmail | string | required | Email address or username |
| password | string | required | Account password (8â64 chars) |
{
"usernameOrEmail": "john@example.com",
"password": "MySecurePass123"
}
{
"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)
"errorCode": "EMAIL_NOT_VERIFIED" and "data": { "email": "..." }.
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.
X-CSRF-Token header.
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Email address linked to the OTP | |
| otp | string | required | Exactly 6 numeric digits |
{
"email": "john@example.com",
"otp": "482931"
}
{
"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
{
"success": false,
"errorCode": "OTP_INVALID",
"message": "Incorrect code. 2 attempts remaining."
}
Resend OTP
âļResends the email verification OTP. Returns a generic success message regardless of whether the email exists (prevents email enumeration).
X-CSRF-Token header.
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Email address to resend OTP to |
{
"success": true,
"message": "If an account with that email exists, a new code has been sent."
}
{
"success": false,
"errorCode": "OTP_RESEND_TOO_SOON",
"message": "Please wait 45 seconds before requesting a new code."
}
Refresh Access Token
âļ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.
| Field | Type | Required | Description |
|---|---|---|---|
| refreshToken | string | optional | Refresh JWT (if not using cookie) |
{
"success": true,
"message": "Token refreshed successfully.",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
// Also sets updated access_token cookie
{
"success": false,
"errorCode": "INVALID_REFRESH_TOKEN",
"message": "Refresh token is invalid or expired. Please log in again."
}
Logout
đ Auth Required âļClears the access_token and refresh_token cookies. Requires a valid access token.
X-CSRF-Token header.
{
"success": true,
"message": "Logged out successfully."
}
Get Current User
đ Auth Required âļReturns the full profile of the authenticated user including subscription details and preferences.
{
"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 }
}
}
}
}
}
Google OAuth
âļ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.
/google and /google/callback) | CSRF: Not required â GET method, no state change on this endpoint.
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
| Param | Type | Description |
|---|---|---|
| code | string | Authorization code from Google |
| state | string | CSRF state parameter (optional) |
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.
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.
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).
X-CSRF-Token header.
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Valid email address, max 255 chars |
{
"email": "john@example.com"
}
{
"success": true,
"message": "Verification code sent to john@example.com. It expires in 10 minutes."
}
{
"success": false,
"errorCode": "USER_ALREADY_EXISTS",
"message": "An account with this email already exists."
}
{
"success": false,
"errorCode": "OTP_RESEND_TOO_SOON",
"message": "Please wait 45 seconds before requesting a new code."
}
Register â Verify OTP
âļ
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.
X-CSRF-Token header.
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Same email used in Step 1 | |
| otp | string | required | 6-digit numeric code from the email |
{
"email": "john@example.com",
"otp": "483910"
}
{
"success": true,
"data": {
"registrationToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
// Store this token and pass it to Step 3. Expires in 15 minutes.
{
"success": false,
"errorCode": "OTP_INVALID",
"message": "Incorrect code. 2 attempts remaining."
}
{
"success": false,
"errorCode": "OTP_EXPIRED",
"message": "This code has expired. Please request a new one."
}
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).
X-CSRF-Token header.
| Field | Type | Required | Description |
|---|---|---|---|
| registrationToken | string | required | JWT returned by Step 2, valid 15 min |
| username | string | required | 3â20 chars, letters/numbers/underscore only |
| password | string | required | 8â64 characters |
| confirmPassword | string | required | Must exactly match password |
| name | string | optional | Display name, max 100 chars |
{
"registrationToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"username": "johndoe",
"password": "MySecurePass123",
"confirmPassword": "MySecurePass123",
"name": "John Doe"
}
{
"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)
{
"success": false,
"errorCode": "REGISTRATION_TOKEN_INVALID",
"message": "Registration token is invalid or has expired. Please start again."
}
{
"success": false,
"errorCode": "USER_ALREADY_EXISTS",
"message": "Username is already taken."
}
Register â Resend OTP
âļ
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.
X-CSRF-Token header.
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Same email used in /register/init |
{
"success": true,
"message": "Verification code sent to john@example.com. It expires in 10 minutes."
}
{
"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.
| Route | Limit | Window | Reason |
|---|---|---|---|
GET /csrf-token | 30 req | 1 hour | Token issuance â low-risk read |
POST /register | 5 req | 1 hour | Slow down bot account creation |
POST /login | 10 req | 15 min | Brute-force password protection |
POST /verify-email | 10 req | 15 min | Limit OTP guessing attempts |
POST /resend-otp | 5 req | 15 min | IP gate + 60s per-email cooldown in service |
POST /refresh | 30 req | 15 min | Tokens expire every 15 min â headroom for legit clients |
GET /google | 10 req | 5 min | OAuth flow spam prevention (shared with callback) |
GET /google/callback | 10 req | 5 min | Shared with /google redirect |
GET /me | â | â | No limit |
POST /logout | â | â | No limit |
POST /register/init | 5 req | 15 min | Prevents email bombing |
POST /register/verify | 10 req | 15 min | OTP brute-force protection |
POST /register/complete | 5 req | 1 hour | Low frequency expected; matches /register |
POST /register/resend | 5 req | 15 min | IP gate + 60s per-email service cooldown |
{
"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.
| Route | CSRF Required | Why |
|---|---|---|
GET /csrf-token | No | Issues the token â GET method, no state change |
POST /register | Yes | Creates an account |
POST /login | Yes | Sets auth cookies |
POST /verify-email | Yes | Sets auth cookies on success |
POST /resend-otp | Yes | Triggers email send |
POST /refresh | No | Refresh token itself is proof of identity â no cookie CSRF vector |
GET /me | No | GET method, read-only |
POST /logout | Yes | Clears auth cookies |
GET /google | No | GET method |
GET /google/callback | No | GET method |
POST /register/init | Yes | Triggers email send |
POST /register/verify | Yes | Consumes OTP, issues registration token |
POST /register/complete | Yes | Creates account, sets auth cookies |
POST /register/resend | Yes | Triggers email send |
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.
// âââ 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
// 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 }, });
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.
// 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
// 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 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.
// 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).