Skip to content

Auth and Sessions

Strategy

Username and password. No OAuth, no passkey, no magic link at launch. Account is an email address used as a unique username — display name is separate and chosen during character creation.

Password Storage

Workers have access to crypto.subtle. Use PBKDF2-SHA256 with 200,000 iterations.

salt   = crypto.getRandomValues(new Uint8Array(16))  → stored as base64
hash   = PBKDF2(password, salt, 200_000, SHA-256, 32 bytes) → stored as base64

Store both in the account D1 row. Never store plaintext. Never log passwords.

On verify: re-derive with stored salt; compare with timing-safe compare (crypto.subtle.verify over HMAC of both to avoid early-exit).

Token Model

Two-token pattern: short-lived access JWT + long-lived opaque refresh token.

Token TTL Storage
Access JWT 30 minutes Memory only (client JS variable)
Refresh token 7 days HttpOnly; Secure; SameSite=Strict cookie

Access JWT — signed with HMAC-SHA256 using JWT_SECRET binding. Payload:

{
  "sub": "account_id",
  "cid": "character_id",
  "iat": 1234567890,
  "exp": 1234567890
}

cid is the active character. An account can have multiple characters; the access token binds to one. Switching characters requires a new token pair.

Refresh token — 32 random bytes, base64url-encoded, stored in KV:

key:   refresh:{sha256(token)}
value: { account_id, character_id, issued_at, expires_at }
TTL:   7 days

Storing the hash of the token in KV (not the raw token) means a KV breach does not expose usable tokens.

Turnstile Bot Protection

Cloudflare Turnstile is required on registration and login. Invisible in most cases; triggers a challenge only on suspicious signals.

Client Side

Load Turnstile widget on /login and /register pages:

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<div class="cf-turnstile" data-sitekey="{TURNSTILE_SITE_KEY}"></div>

The widget appends a hidden cf-turnstile-response field to the form. Client submits this token as part of the request body.

Server Side

Worker validates the token before any business logic:

POST https://challenges.cloudflare.com/turnstile/v0/siteverify
Body: { secret: env.TURNSTILE_SECRET_KEY, response: body["cf-turnstile-response"], remoteip: request.headers.get("CF-Connecting-IP") }

If success: false in response → return 403 turnstile_failed immediately. Do not proceed to password check.

Bindings: - TURNSTILE_SITE_KEY — public; included in Pages environment variables (injected at build time into SvelteKit PUBLIC_TURNSTILE_SITE_KEY) - TURNSTILE_SECRET_KEY — secret; stored as Workers Secret binding, never in client bundle

Endpoints requiring Turnstile: POST /api/auth/register, POST /api/auth/login.

Phase 2: add Turnstile to password reset if implemented.


Auth Endpoints

POST /api/auth/register

Body:  { email, password, display_name }
Check: email unique in D1 (case-insensitive); password ≥ 12 chars
Do:    hash password → insert account row → issue token pair
Return: { access_token } + Set-Cookie: refresh_token=...

Normalise email to lowercase before storage and all lookups.

POST /api/auth/login

Body:  { email, password }
Do:    lookup account by email → verify hash → issue token pair
Return: { access_token } + Set-Cookie: refresh_token=...

On fail: return 401 with generic message "Invalid credentials". Do not distinguish "email not found" from "wrong password".

POST /api/auth/refresh

Cookie: refresh_token
Do:    hash cookie value → lookup KV → verify not expired → issue new access JWT
       (refresh token itself does NOT rotate on every call; rotates only when ≤ 1 day remains)
Return: { access_token }

POST /api/auth/logout

Cookie: refresh_token
Do:    hash cookie value → delete KV key → clear cookie
Return: 204

Auth Middleware

Every protected Worker route runs:

async function requireAuth(request: Request, env: Env): Promise<{ accountId: string; characterId: string }> {
  const header = request.headers.get('Authorization');
  if (!header?.startsWith('Bearer ')) throw new Response('Unauthorized', { status: 401 });
  const token = header.slice(7);
  const payload = await verifyJWT(token, env.JWT_SECRET);
  if (!payload || payload.exp < Date.now() / 1000) throw new Response('Unauthorized', { status: 401 });
  return { accountId: payload.sub, characterId: payload.cid };
}

Client sends Authorization: Bearer <access_token> on every API request. The refresh cookie is sent automatically by the browser on refresh calls only.

Rate Limiting

Enforced at the Cloudflare WAF layer — no Workers code needed for these.

Target Rule Limit
POST /api/auth/login Per IP 5 req/min; block 10 min on exceed
POST /api/auth/register Per IP 3 req/min; block 60 min on exceed
All /api/* Per IP 120 req/min; block 1 min on exceed
POST /api/board/*/accept Per character (enforced in DO) Idempotency key; no double-accept
Market order post Per character (enforced in Market DO) 5 orders/character/10 min

WAF rate limit rules are configured in the Cloudflare dashboard under the aelghar-api Worker service. They are not in source code.

Account Management

Phase 1 scope: no email verification, no password reset flow. Both are deferred. Users who lose their password cannot recover their account at launch. This is documented in the launch assumptions.

Phase 2 adds: email verification (Cloudflare Email Workers), password reset via time-limited token (KV TTL).

Security Notes

  • JWT_SECRET is a Workers Secret binding. Never hardcode. Minimum 256 bits of entropy.
  • Refresh tokens are HttpOnly; Secure; SameSite=Strict. JavaScript cannot read them.
  • All auth endpoints enforce Content-Type: application/json on request. Reject anything else.
  • Cloudflare handles TLS termination. All traffic to origin Workers is HTTPS.
  • SQL queries use D1 prepared statements only. No string interpolation into queries.

Error Responses

All auth errors return JSON:

{ "error": "invalid_credentials" }
{ "error": "email_taken" }
{ "error": "password_too_short" }
{ "error": "token_expired" }
{ "error": "token_invalid" }

HTTP status codes: 400 for validation errors, 401 for auth failures, 409 for conflicts (email taken), 429 for rate limit.