Skip to content

Architecture

The auth service runs on Cloudflare Workers with two storage backends:

Durable storage for users, projects, sessions, and PINs. ACID transactions for token rotation.
Ephemeral storage for PKCE codes (5-min TTL), rate limit counters, and magic link tokens (15-min TTL).
users
├── id (TEXT, PK) -- usr_xxx
├── email (TEXT, UNIQUE)
├── password_hash (TEXT) -- Argon2id, nullable
├── email_verified (INT) -- 0 or 1
├── created_at (TEXT)
└── updated_at (TEXT)
projects
├── id (TEXT, PK) -- proj_xxx (= client_id)
├── name (TEXT)
├── owner_id (TEXT, FK)
├── client_secret_hash (TEXT) -- Argon2id
├── redirect_uris (TEXT) -- JSON array, exact-match
├── signing_key (TEXT) -- 32-byte hex (HS256)
├── auth_methods (TEXT) -- JSON: ["password","pin","magic_link"]
├── branding (TEXT) -- JSON: colors, logo, mode
├── registration (TEXT) -- "open" | "closed" | "invite_only"
└── created_at (TEXT)
project_users
├── project_id (TEXT, PK)
├── user_id (TEXT, PK)
├── role (TEXT) -- "admin" | "member"
├── status (TEXT) -- "active" | "blocked"
├── metadata (TEXT) -- JSON, reserved
└── joined_at (TEXT)
project_pins
├── id (TEXT, PK) -- pin_xxx
├── project_id (TEXT, FK)
├── pin_hash (TEXT) -- Argon2id
├── label (TEXT)
├── status (TEXT) -- "active" | "revoked"
├── privileges (TEXT) -- JSON array
├── created_at (TEXT)
└── revoked_at (TEXT)
refresh_tokens
├── id (TEXT, PK) -- 64-char hex
├── user_id (TEXT) -- NULL for PIN sessions
├── project_id (TEXT, FK)
├── expires_at (TEXT)
├── revoked (INT) -- 0 or 1
├── revoked_at (TEXT)
├── is_pin_session (INT)
├── pin_id (TEXT)
└── created_at (TEXT)

Each project has its own signing_key. A token issued for proj_gym cannot be verified by proj_trip — they use different HS256 secrets. This means:

  • A compromise of one app’s signing key doesn’t affect others
  • Tokens cannot be replayed across projects
  • Each app independently decides what claims to trust

Access tokens are verified locally by client apps — no network call to the auth service. They embed all claims (role, status, email) needed for authorization decisions.

Refresh tokens require a round-trip to the auth service. This is where:

  • User blocking is enforced
  • PIN revocation is checked
  • Token rotation happens
  • Session tracking is maintained
PurposeAlgorithmDetails
Password hashingArgon2id19 MiB memory, 2 iterations, 1 parallelism
PIN hashingArgon2idSame parameters as passwords
Access token signingHS256Per-project 256-bit key
ID token signing (OIDC)ES256Single EC P-256 key pair
State encryptionAES-GCM256-bit key derived from JWT secret
Refresh tokenRandom256-bit (64 hex chars), stored plaintext
PKCE verifierRandom256-bit (32 bytes, base64url)

All rate limits are enforced via KV counters with sliding windows. They fail closed — if KV is unavailable, the request is denied.

EndpointLimitWindow
Login (password/PIN)5 attempts15 min per IP per project
Signup3 attempts1 hour per IP
Magic link send3 sends1 hour per email
Token exchange20 requests60 sec per client_id
1. User visits app.beshoy.ai
2. Middleware checks for valid access_token cookie
├── Valid → proceed to app
├── Expired → try refresh (step 5)
└── Missing → redirect to auth (step 3)
3. Redirect to auth.beshoy.ai/oauth/authorize
└── With: client_id, redirect_uri, code_challenge, state
4. User authenticates at auth.beshoy.ai
├── Password → verify Argon2 hash
├── PIN → verify against all project PINs
└── Magic link → verify KV token
└── On success: store PKCE data in KV, redirect back with code
5. App exchanges code at POST /oauth/token
├── Verify client_secret
├── Verify PKCE (code_verifier vs stored challenge)
├── Create access token (HS256, 5 min)
├── Create refresh token (stored in D1)
└── Return both tokens
6. App sets cookies, user continues
7. On access token expiry:
POST /oauth/token with grant_type=refresh_token
├── Verify refresh token exists + not expired
├── Check user status (blocked → 403)
├── Check PIN status if PIN session (revoked → 403)
├── Revoke old refresh token (rotation)
├── Issue new access + refresh tokens
└── Return both tokens