đ 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 {DASH_ORIGIN}/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 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
| Param | Type | Description |
|---|---|---|
| code | string | Authorization code â exchanged for tokens server-side |
| state | string | CSRF state UUID â validated against KV before proceeding |
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.
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.
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.
X-CSRF-Token header.
- 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>
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Email address of the account to reset |
{
"email": "john@example.com"
}
{
"success": true,
"message": "If an account with that email exists, you will receive a password reset link shortly."
}
{
"success": false,
"errorCode": "RATE_LIMITED",
"message": "Too many requests. Please wait and try again."
}
// 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...
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.
X-CSRF-Token header.
- 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
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | required | Raw token from the reset URL query parameter |
| password | string | required | New password, 8â64 characters |
| confirmPassword | string | required | Must exactly match password |
{
"token": "a1b2c3d4e5f6...",
"password": "NewSecurePass456",
"confirmPassword": "NewSecurePass456"
}
{
"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
{
"success": false,
"errorCode": "RESET_TOKEN_INVALID",
"message": "This password reset link is invalid or has already been used."
}
{
"success": false,
"errorCode": "RESET_TOKEN_EXPIRED",
"message": "This password reset link has expired. Please request a new one."
}
{
"success": false,
"errorCode": "RESET_TOKEN_MAX_ATTEMPTS",
"message": "This reset link has been invalidated after too many attempts. Please request a new one."
}
// 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.
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 |
POST /forgot-password | 3 req | 1 hour | Tight â each request triggers an outbound email |
POST /reset-password | 5 req | 15 min | Token submission; service layer also caps at 5 attempts |
{
"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 |
POST /forgot-password | Yes | Triggers email send, state-mutating |
POST /reset-password | Yes | Mutates user password, state-mutating |
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).