Auth System
Overview
Section titled “Overview”Authentication uses JWT (JSON Web Tokens) with a short-lived access token and a longer-lived refresh token. There is no session store — everything is stateless, which works well in the Cloudflare Workers environment.
Token types
Section titled “Token types”| Access Token | Refresh Token | |
|---|---|---|
| Signed with | JWT_SECRET | REFRESH_SECRET_KEY |
| Expiry (USER) | 30 minutes | 7 days |
| Expiry (ADMIN/AGENT) | 6 hours | 7 days |
| Payload | { id, username, email, role, agentType } | { id, type: 'refresh' } |
| Stored in | localStorage (frontend) | localStorage (frontend) |
The two tokens use different secrets so that a refresh token cannot be used as an access token and vice versa. The middleware also explicitly checks user.type !== 'refresh' at access endpoints as an extra guard.
Security note: Storing tokens in
localStorageexposes them to XSS attacks. If any third-party JS is ever added to the site (analytics, chat widgets, ads), a compromised script could exfiltrate tokens. The more secure approach ishttpOnlycookies for the refresh token. This is tracked as a known issue.
Auth flow
Section titled “Auth flow”Login → POST /api/auth/login ↓ Returns: { token, refreshToken, user } ↓ Frontend stores both in localStorage
Protected request → GET /api/submissions ↓ Authorization: Bearer <token> ↓ authenticateToken middleware verifies JWT ↓ Attaches req.user = decoded payload
Token expired → 401 "Token expired" ↓ Frontend calls POST /api/auth/refresh { refreshToken } ↓ Returns new access token ↓ Frontend retries original requestMiddleware
Section titled “Middleware”authenticateToken
Section titled “authenticateToken”Located in middleware/auth.js. Verifies the Bearer token and attaches req.user.
// Usage in any routerouter.get('/my-route', authenticateToken, (req, res) => { const userId = req.user.id; const role = req.user.role; // ...});requireAdmin / requireAgent
Section titled “requireAdmin / requireAgent”Shorthand role guards. Always used after authenticateToken.
router.delete('/users/:id', authenticateToken, requireAdmin, (req, res) => { // Only ADMIN reaches here});
router.get('/tickets', authenticateToken, requireAgent, (req, res) => { // ADMIN or AGENT reaches here});requireRole(...roles)
Section titled “requireRole(...roles)”Generic role guard for custom combinations:
router.put('/something', authenticateToken, requireRole('ADMIN', 'AGENT'), handler);Role hierarchy
Section titled “Role hierarchy”ADMIN ↓ can manageAGENT (+ agentType for fine-grained permissions) ↓ can use features assigned to their agent typeUSER ↓ public site + own bookings + KYCImportant: role is always re-fetched from DB on writes
Section titled “Important: role is always re-fetched from DB on writes”For any operation that changes data, routes fetch the user’s current role directly from the database rather than trusting the role embedded in the JWT. This prevents a scenario where an admin demotes an agent but the agent’s still-valid token lets them continue acting as an agent.
// Example from routes/submissions.jsrouter.put('/:id', authenticateToken, ..., (req, res) => { // Re-fetch from DB — don't trust req.user.role for this decision db.get('SELECT role, agentType FROM users WHERE id = ?', [req.user.id], (err, dbUser) => { const role = dbUser.role; // use this, not req.user.role // ... });});Apply this pattern to any route where the role determines what data is returned or modified.
KYC status flow
Section titled “KYC status flow”KYC (Know Your Customer) is separate from authentication but gated by it:
NOT_SUBMITTED → (user submits info + files) → SUBMITTED → (admin reviews) → APPROVED ↘ NOT_SUBMITTED (rejected, user resubmits)The kyc_status column on users is checked by the frontend to determine which onboarding step to show. KYC approval is typically a prerequisite for agents to access certain features, enforced at the UI level (not yet strictly enforced at the API level — a known gap).
Password rules
Section titled “Password rules”- Minimum 8 characters
- At least one uppercase, one lowercase, one digit
- Validated by
validatePassword()inroutes/auth.js - Always hashed with bcrypt (cost factor 10) — never stored or compared in plaintext
Reserved usernames
Section titled “Reserved usernames”The following usernames cannot be registered publicly: admin, administrator, root, superuser, system, support. This blocks a common attack where someone registers “admin” hoping the app grants elevated privileges based on username.