Skip to content

Auth System

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.

Access TokenRefresh Token
Signed withJWT_SECRETREFRESH_SECRET_KEY
Expiry (USER)30 minutes7 days
Expiry (ADMIN/AGENT)6 hours7 days
Payload{ id, username, email, role, agentType }{ id, type: 'refresh' }
Stored inlocalStorage (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 localStorage exposes 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 is httpOnly cookies for the refresh token. This is tracked as a known issue.

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 request

Located in middleware/auth.js. Verifies the Bearer token and attaches req.user.

// Usage in any route
router.get('/my-route', authenticateToken, (req, res) => {
const userId = req.user.id;
const role = req.user.role;
// ...
});

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
});

Generic role guard for custom combinations:

router.put('/something', authenticateToken, requireRole('ADMIN', 'AGENT'), handler);
ADMIN
↓ can manage
AGENT (+ agentType for fine-grained permissions)
↓ can use features assigned to their agent type
USER
↓ public site + own bookings + KYC

Important: 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.js
router.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 (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).

  • Minimum 8 characters
  • At least one uppercase, one lowercase, one digit
  • Validated by validatePassword() in routes/auth.js
  • Always hashed with bcrypt (cost factor 10) — never stored or compared in plaintext

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.