Auth and Security
Token architecture
Two tokens are issued on login and maintained in the tokens table:
| Token | Storage | Lifetime | Purpose |
|---|---|---|---|
| Access token (JWT) | Frontend cookie access_token | 15 min (JWT_ACCESS_EXPIRES_IN) | Sent as Authorization: Bearer on every request |
| Refresh token (opaque hex) | HTTP-only backend cookie | 7 days (JWT_REFRESH_EXPIRES_DAYS) | Rotated on each use via /api/auth/refresh |
Login flow
1. POST /api/auth/login { email, password }
→ Backend verifies bcrypt hash
→ Issues signed JWT access token (15 min)
→ Generates refresh token, persists in tokens table
→ Sets refresh_token as HttpOnly cookie
→ Returns { user, access_token } in body
2. Frontend stores access_token in cookie:
SameSite=Strict, max-age=900, Secure in productionRefresh flow
3. Background query runs every 12 min (via SessionKeepAlive)
→ POST /api/auth/refresh (cookie sent automatically)
→ Backend validates refresh token (not revoked, not expired)
→ Revokes old refresh token row in DB
→ Inserts new refresh token row
→ Returns new { access_token } + sets new cookie
4. Axios interceptor triggers on unexpected 401:
→ Refreshes then replays original request once
→ On second failure: clears access_token, redirects to /loginLogout flow
5. POST /api/auth/logout
→ Backend revokes refresh_token in DB
→ Clears refresh_token cookie
→ Frontend clears access_token cookieCookie settings
Backend res.ok() helper applies:
| Environment | access_token cookie | refresh_token cookie |
|---|---|---|
| Development | SameSite=Strict | SameSite=Lax; HttpOnly |
| Production | SameSite=Strict; Secure | SameSite=None; Secure; HttpOnly |
Frontend sets the access token cookie directly (not HttpOnly). This is a known exposure surface — see risks below.
Role model
Two roles exist, enforced via requireAuth(request, allowedRoles) in every route handler:
| Role | Access |
|---|---|
admin | User management, package management, route creation, logs |
distributor | Own packages, own daily route, stop arrival, package status updates |
Public routes (no auth)
POST /api/auth/loginPOST /api/auth/forgotPasswordPATCH /api/auth/changePwd(token-based, not Bearer)GET /api/tracking/:trackingToken(public tracking page)
Password reset flow
1. POST /api/auth/forgotPassword { email }
→ Always responds with neutral message (no email enumeration)
→ If email exists: generates reset_pwd_token (24h), sends email
2. PATCH /api/auth/changePwd { reset_pwd_token }
→ Returns { valid: true } (token check, no password change)
3. PATCH /api/auth/changePwd { reset_pwd_token, new_password }
→ Validates token not revoked, not expired
→ Updates password_hash, revokes token
→ Returns { message: "Password changed successfully" }Account activation flow
1. Admin: POST /api/users/create { name, email, role }
→ User created with is_active=FALSE
→ activate_account_token generated (24h expiry)
→ Activation email sent to user
2. User clicks link → POST /api/auth/activateAccount?token=...
→ User sets their own password
→ is_active=TRUE, token revokedKnown risks
| Severity | Risk | Source |
|---|---|---|
| High | JWT_SECRET has a hardcoded fallback in envConfig.ts. If not set in production, anyone with the repo can forge access tokens. | backend-js/src/app/config/envConfig.ts:14 |
| High | DEFAULT_USER_PASSWORD has same hardcoded fallback. New users get a known password if the env var is unset. | backend-js/src/app/config/envConfig.ts:37 |
| Medium | handleError forwards internal error .message in HTTP 500 responses. MySQL or library error messages can leak table names and stack details. | backend-js/src/app/lib/response.ts:83 |
| Medium | @upstash/ratelimit and @upstash/redis are installed but not wired. Login, forgotPassword, and tracking endpoints are unprotected from brute force. | backend-js/package.json |
| Medium | Access token stored in non-HttpOnly cookie (access_token). XSS can read it. Refresh token is safe in HttpOnly cookie. | frontend-app/app/lib/api/helpers/client.ts |
| Low | GOOGLE_DIRECTIONS_API_KEY uses TypeScript ! assertion without startup guard. Silent undefined at runtime if var is missing. | backend-js/src/app/config/envConfig.ts:9 |
| Low | MySQL env vars not validated at startup. Pool silently accepts undefined; first query fails without a descriptive startup error. | backend-js/src/app/config/envConfig.ts:1-5 |
Fixing the hardcoded secrets
The minimum required change in envConfig.ts:
// Current (unsafe):
export const jwt_secret = process.env.JWT_SECRET ?? "kjhaf7ya...";
// Should be:
const jwt_secret = process.env.JWT_SECRET;
if (!jwt_secret) throw new Error("JWT_SECRET env var is required");
export { jwt_secret };Apply the same pattern to DEFAULT_USER_PASSWORD and GOOGLE_DIRECTIONS_API_KEY.
Implementing rate limiting
@upstash/ratelimit is already in package.json. To activate it:
- Add
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKENto.env.local. - Create
backend-js/src/app/lib/rateLimit.tswith aRatelimitinstance. - Call the limiter at the top of:
POST /api/auth/login,POST /api/auth/forgotPassword,GET /api/tracking/:token.