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:
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:
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¶
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_SECRETis 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/jsonon 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.