31 KiB

Authentication and Authorization Issues

This guide covers authentication (who you are) and authorization (what you can do) problems in Changemaker Lite V2.

Overview

Authentication System

Changemaker Lite V2 uses JWT-based authentication:

  • Access tokens - Short-lived (15 minutes), stored in memory
  • Refresh tokens - Long-lived (7 days), stored in database
  • bcrypt passwords - Hashed with salt (10 rounds)
  • Token rotation - New refresh token on each refresh

Authorization System

Role-based access control (RBAC) with 5 roles:

Role Level Permissions
SUPER_ADMIN 5 Full access to everything
INFLUENCE_ADMIN 4 Manage campaigns, responses, email queue
MAP_ADMIN 3 Manage locations, cuts, shifts, canvass
USER 2 View public content, canvass (if assigned shift)
TEMP 1 Very limited - shift signup confirmation only

Security Features

  • Password policy - 12+ chars, uppercase, lowercase, digit
  • User enumeration prevention - Generic error messages
  • Rate limiting - 10 requests/min on auth endpoints
  • Refresh token rotation - Atomic transaction prevents race conditions
  • Redis authentication - Required password for Redis connection

Login Failures

Invalid Credentials

Severity: 🟡 Medium

Symptoms

{
  "error": "Unauthorized",
  "message": "Invalid credentials"
}

Same message for:

  • User not found
  • Wrong password
  • User suspended

This is intentional (prevents user enumeration).

Common Causes

  1. Wrong password - Password incorrect
  2. User doesn't exist - Email not registered
  3. Typo in email - Email address wrong
  4. Account suspended - User marked as suspended

Solutions

Solution 1: Verify user exists

# Check database
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "SELECT email, role FROM \"User\" WHERE email = 'user@example.com';"

# If no result, user doesn't exist

Solution 2: Reset password

# Generate bcrypt hash for new password
docker compose exec api node -e "
const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync('NewPassword123!', 10);
console.log(hash);
"

# Update password
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "UPDATE \"User\" SET password = '\$2a\$10\$...' WHERE email = 'user@example.com';"

Solution 3: Create missing user

# Via API
curl -X POST http://localhost:4000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "SecurePass123!",
    "name": "User Name"
  }'

# Or via admin UI at /app/users

Solution 4: Check for suspended account

# Check user status
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "SELECT email, role, \"createdAt\" FROM \"User\" WHERE email = 'user@example.com';"

# If suspended field exists and is true, unsuspend:
# (Note: V2 doesn't have suspended field yet, but may be added)

Solution 5: Check password requirements

Password must meet requirements:

  • 12+ characters
  • At least 1 uppercase letter
  • At least 1 lowercase letter
  • At least 1 digit
# Valid examples:
SecurePass123!
MyP@ssword99
Admin12345678

Prevention

  • Password manager - Use password manager to avoid typos
  • Password reset flow - Implement forgot password feature
  • Clear requirements - Display password requirements on register
  • User-friendly errors - Guide users without revealing if email exists

!!! warning "User Enumeration" The same error message for "user not found" and "wrong password" is intentional security behavior to prevent attackers from discovering valid email addresses.


Account Suspended

Severity: 🟠 High

Symptoms

{
  "error": "Forbidden",
  "message": "Account suspended"
}

User can't log in even with correct credentials.

Common Causes

  1. Manual suspension - Admin suspended account
  2. Security violation - Account flagged for suspicious activity
  3. Terms violation - Account suspended for policy violation

Solutions

Solution 1: Check suspension status

# Check if user has suspended flag (if implemented)
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "SELECT email, role FROM \"User\" WHERE email = 'user@example.com';"

Solution 2: Unsuspend account

# If suspension field exists:
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "UPDATE \"User\" SET suspended = false WHERE email = 'user@example.com';"

# Or delete user and recreate

Solution 3: Contact administrator

If you're a user:

  1. Contact system administrator
  2. Provide your email address
  3. Wait for account review

Prevention

  • Suspension policy - Clear policy on suspension reasons
  • Appeal process - Allow users to appeal suspensions
  • Audit trail - Log suspension reasons and who suspended

!!! note "V2 Status" V2 doesn't currently have a suspended field. This section is for future implementation or if added via custom migration.


Email Not Verified

Severity: 🟢 Low

Symptoms

{
  "error": "Forbidden",
  "message": "Email not verified. Please check your email for verification link."
}

Common Causes

  1. Email not verified - User didn't click verification link
  2. Verification email not received - Email went to spam
  3. Verification link expired - Link older than 24 hours

Solutions

Solution 1: Check verification status

# Check if emailVerified field exists
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "SELECT email, \"createdAt\" FROM \"User\" WHERE email = 'user@example.com';"

Solution 2: Manually verify email

# Mark email as verified
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "UPDATE \"User\" SET \"emailVerified\" = true WHERE email = 'user@example.com';"

Solution 3: Resend verification email

# Via API (if endpoint exists)
curl -X POST http://localhost:4000/api/auth/resend-verification \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'

Solution 4: Check spam folder

Verification emails may be marked as spam. Check:

  1. Spam/Junk folder
  2. Promotions tab (Gmail)
  3. Email filters

Prevention

  • Clear instructions - Tell users to check spam
  • From address - Use recognizable from address
  • SPF/DKIM/DMARC - Configure email authentication
  • Resend option - Easy way to resend verification

!!! note "V2 Status" V2 doesn't currently require email verification for login. This section is for future implementation.


Token Issues

Token Expired

Severity: 🟡 Medium

Symptoms

{
  "error": "Unauthorized",
  "message": "Token expired"
}

Or:

Error: jwt expired

Common Causes

  1. Access token expired - Normal after 15 minutes inactive
  2. Refresh token expired - After 7 days
  3. System clock skew - Server/client time difference

Solutions

Solution 1: Frontend auto-refresh

Frontend automatically refreshes tokens on 401. If this fails:

// Check refresh token in localStorage
const storage = JSON.parse(localStorage.getItem('auth-storage'));
console.log('Has refresh token:', !!storage?.state?.refreshToken);

// If missing, need to log in again

Solution 2: Manual refresh

# Refresh token via API
curl -X POST http://localhost:4000/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "YOUR_REFRESH_TOKEN"}'

# Returns new access + refresh tokens

Solution 3: Check token expiration

// Decode JWT to see expiration
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
const payload = JSON.parse(atob(token.split('.')[1]));
console.log('Expires:', new Date(payload.exp * 1000));
console.log('Now:', new Date());

Solution 4: Check system time

# On server
docker compose exec api date

# Should match actual time
# If not, sync clock:
sudo ntpdate -s time.nist.gov

Solution 5: Log in again

If refresh token also expired:

  1. You'll be redirected to login automatically
  2. Log in with email/password
  3. New tokens issued

Prevention

  • Sliding sessions - Auto-refresh extends session
  • Long refresh window - 7-day refresh token validity
  • Activity tracking - Keep track of last activity
  • Clock sync - Keep server time accurate

Invalid Token

Severity: 🟠 High

Symptoms

{
  "error": "Unauthorized",
  "message": "Invalid token"
}

Or:

Error: jwt malformed
Error: invalid signature
Error: invalid algorithm

Common Causes

  1. Corrupted token - LocalStorage corruption
  2. Wrong secret - JWT_ACCESS_SECRET changed
  3. Tampered token - Someone modified the token
  4. Format error - Not a valid JWT

Solutions

Solution 1: Verify JWT format

Valid JWT has 3 parts separated by dots:

const token = 'header.payload.signature';
console.log(token.split('.').length); // Should be 3

// Example valid token:
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
// eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzA4MDAwMDAwLCJleHAiOjE3MDgwMDA5MDB9.
// signature-here

Solution 2: Clear localStorage and re-login

// In browser console
localStorage.clear();
// Then log in again

Solution 3: Verify JWT_ACCESS_SECRET

# Check API .env
docker compose exec api sh -c 'echo $JWT_ACCESS_SECRET'

# Should be:
# - At least 32 characters
# - Never changed (changing invalidates all tokens)
# - Different from JWT_REFRESH_SECRET

Solution 4: Test token verification

# Test token via API
curl http://localhost:4000/api/auth/me \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

# If returns user, token is valid
# If 401, token is invalid

Solution 5: Check API logs

# View token validation errors
docker compose logs api | grep -i "jwt\|token"

# Common errors:
# - "jwt malformed" - not a valid JWT format
# - "invalid signature" - wrong secret or tampered
# - "invalid algorithm" - algorithm mismatch

Prevention

  • Secure secrets - Use strong, random secrets
  • Never change secrets - Changing logs out all users
  • Don't expose secrets - Never commit to git
  • Token validation - Validate before using

Token Not Found in Header

Severity: 🟡 Medium

Symptoms

{
  "error": "Unauthorized",
  "message": "No token provided"
}

Or:

{
  "error": "Unauthorized",
  "message": "Invalid authorization header format"
}

Common Causes

  1. Missing Authorization header - Header not sent
  2. Wrong header format - Not "Bearer token"
  3. Token not in localStorage - User not logged in
  4. API client misconfigured - axios interceptor not working

Solutions

Solution 1: Check if logged in

// In browser console
const storage = JSON.parse(localStorage.getItem('auth-storage'));
console.log('Access token:', storage?.state?.accessToken);

// If null, not logged in

Solution 2: Verify header format

# Correct format
curl http://localhost:4000/api/users \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

# Wrong formats:
# -H "Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."  # Missing "Bearer"
# -H "Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."       # Wrong header name
# -H "Authorization: Token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."  # Wrong prefix

Solution 3: Check axios interceptor

In admin/src/lib/api.ts:

// Request interceptor should add token
api.interceptors.request.use((config) => {
  const storage = JSON.parse(localStorage.getItem('auth-storage') || '{}');
  const token = storage?.state?.accessToken;

  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }

  return config;
});

Solution 4: Test with curl

# Get token from localStorage (browser console)
const token = JSON.parse(localStorage.getItem('auth-storage')).state.accessToken;
console.log(token);

# Test with curl
curl http://localhost:4000/api/users \
  -H "Authorization: Bearer [paste-token-here]"

Solution 5: Log in again

# If all else fails, log out and log in
localStorage.clear();
# Navigate to /login

Prevention

  • axios interceptor - Automatically add token to requests
  • Error handling - Redirect to login on 401
  • Token persistence - Store token in localStorage
  • Testing - Test auth flow regularly

Refresh Token Invalid

Severity: 🟠 High

Symptoms

{
  "error": "Unauthorized",
  "message": "Invalid refresh token"
}

Auto-refresh fails, user logged out.

Common Causes

  1. Refresh token expired - Older than 7 days
  2. Token revoked - User logged out explicitly
  3. Token not in database - Database was reset
  4. Token rotation - Token already used (consumed)

Solutions

Solution 1: Check refresh token in database

# Find refresh token
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "SELECT id, \"userId\", \"expiresAt\", \"createdAt\" FROM \"RefreshToken\"
      WHERE token = 'YOUR_REFRESH_TOKEN_HASH';"

# If no result, token doesn't exist (revoked or expired)

Solution 2: Check expiration

# Find all refresh tokens for user
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "SELECT id, \"expiresAt\", \"createdAt\" FROM \"RefreshToken\"
      WHERE \"userId\" = 'USER_UUID'
      ORDER BY \"createdAt\" DESC;"

# Check if expiresAt < NOW()

Solution 3: Log in again

Refresh token can't be renewed. Must log in with email/password:

curl -X POST http://localhost:4000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "SecurePass123!"
  }'

Solution 4: Clear old refresh tokens

# Delete expired refresh tokens
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "DELETE FROM \"RefreshToken\" WHERE \"expiresAt\" < NOW();"

# Or delete all refresh tokens for user (logs out all devices)
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "DELETE FROM \"RefreshToken\" WHERE \"userId\" = 'USER_UUID';"

Prevention

  • Long expiration - 7-day refresh token validity
  • Token rotation - New refresh token on each refresh
  • Cleanup job - Delete expired tokens periodically
  • Multi-device support - Multiple refresh tokens per user

Permission Errors

Insufficient Permissions

Severity: 🟡 Medium

Symptoms

{
  "error": "Forbidden",
  "message": "Insufficient permissions"
}

Or role-specific:

{
  "error": "Forbidden",
  "message": "Requires one of: SUPER_ADMIN, MAP_ADMIN"
}

Common Causes

  1. Wrong role - User doesn't have required role
  2. TEMP user - Temporary user trying to access admin features
  3. Feature disabled - Feature flag not enabled
  4. Endpoint restricted - Endpoint requires specific role

Solutions

Solution 1: Check user role

# View user role
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "SELECT email, role FROM \"User\" WHERE email = 'user@example.com';"

# Roles:
# SUPER_ADMIN - full access
# INFLUENCE_ADMIN - campaigns/responses
# MAP_ADMIN - locations/cuts/shifts
# USER - public content + canvass
# TEMP - very limited

Solution 2: Update user role

# Via Prisma Studio (recommended)
docker compose exec api npx prisma studio
# Navigate to User table, edit role field

# Or via SQL
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "UPDATE \"User\" SET role = 'MAP_ADMIN' WHERE email = 'user@example.com';"

Solution 3: Check endpoint requirements

In API code (api/src/modules/*/routes.ts):

// Example from campaigns.routes.ts
router.post('/',
  authenticate,  // Must be logged in
  requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'),  // Must be admin
  validate(createCampaignSchema),
  campaignsController.create
);

Solution 4: Verify feature flags

# Check .env for feature flags
cat .env | grep ENABLE

# Example:
ENABLE_MEDIA_FEATURES=true
LISTMONK_SYNC_ENABLED=true

Solution 5: Check TEMP user restrictions

TEMP users are created during shift signup and have very limited permissions:

// TEMP users blocked by requireNonTemp middleware
router.get('/my-data',
  authenticate,
  requireNonTemp,  // Blocks TEMP users
  controller.getData
);

To convert TEMP to USER:

docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "UPDATE \"User\" SET role = 'USER' WHERE email = 'temp@example.com';"

Prevention

  • Clear role descriptions - Document what each role can do
  • Role matrix - Table showing role → permission mapping
  • Upgrade flow - Easy way for users to upgrade from TEMP to USER
  • Helpful errors - Show which role is required

Role Restrictions

Severity: 🟢 Low

Symptoms

User logged in but can't access certain features.

Common Causes

  1. Not enough permissions - Role too low for feature
  2. Feature flag - Feature not enabled
  3. TEMP user - Temporary account with restrictions

Solutions

Solution 1: View role permissions

Feature SUPER_ADMIN INFLUENCE_ADMIN MAP_ADMIN USER TEMP
User management
Settings
Campaigns
Responses
Email queue
Locations
Cuts
Shifts
Canvass dashboard
Public campaigns
Public shifts
Volunteer canvass

Solution 2: Request role upgrade

If you need higher permissions:

  1. Contact system administrator
  2. Explain why you need the role
  3. Wait for approval and role change

Solution 3: Create admin account

For first admin (if none exist):

# Connect to database
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2

# Create SUPER_ADMIN
# (Password must be pre-hashed with bcrypt)
INSERT INTO "User" (id, email, password, name, role)
VALUES (
  gen_random_uuid(),
  'admin@example.com',
  '$2a$10$...',  -- bcrypt hash of 'Admin123!'
  'System Admin',
  'SUPER_ADMIN'
);

Prevention

  • Document roles - Clear description of each role
  • Role request process - Easy way to request role upgrade
  • Audit trail - Log role changes
  • Principle of least privilege - Give minimum role needed

Session Issues

Session Timeout

Severity: 🟢 Low

Symptoms

User inactive for a while, then gets logged out.

Current Behavior

V2 uses JWT tokens (not sessions):

  • Access token expires after 15 minutes
  • Refresh token expires after 7 days
  • Auto-refresh on API calls extends session

Solutions

Solution 1: Configure token expiration

In .env:

# Access token (default: 15m)
JWT_ACCESS_EXPIRATION=30m

# Refresh token (default: 7d)
JWT_REFRESH_EXPIRATION=14d

Restart API:

docker compose restart api

Solution 2: Implement activity tracking

// In frontend, track last activity
const updateActivity = () => {
  localStorage.setItem('lastActivity', Date.now().toString());
};

// On any user action
document.addEventListener('click', updateActivity);
document.addEventListener('keypress', updateActivity);

// Check on load
useEffect(() => {
  const lastActivity = parseInt(localStorage.getItem('lastActivity') || '0');
  const now = Date.now();
  const thirtyMinutes = 30 * 60 * 1000;

  if (now - lastActivity > thirtyMinutes) {
    // Log out due to inactivity
    authStore.logout();
  }
}, []);

Prevention

  • Sliding sessions - Auto-refresh extends session
  • Long refresh window - 7-day default
  • Activity tracking - Reset timeout on activity
  • Warning before logout - Show countdown before timeout

Multiple Device Conflicts

Severity: 🟢 Low

Symptoms

User logged in on multiple devices, behavior inconsistent.

Current Behavior

V2 supports multiple devices:

  • Each login creates new refresh token
  • All devices stay logged in independently
  • No device limit by default

Solutions

Solution 1: View user's devices

# List all refresh tokens for user
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "SELECT id, \"createdAt\", \"expiresAt\" FROM \"RefreshToken\"
      WHERE \"userId\" = 'USER_UUID'
      ORDER BY \"createdAt\" DESC;"

# Each row = one device/session

Solution 2: Log out all devices

# Delete all refresh tokens for user
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "DELETE FROM \"RefreshToken\" WHERE \"userId\" = 'USER_UUID';"

# User must log in again on all devices

Solution 3: Log out specific device

# Delete specific refresh token
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "DELETE FROM \"RefreshToken\" WHERE id = 'REFRESH_TOKEN_UUID';"

Solution 4: Implement device limit

In api/src/modules/auth/auth.service.ts:

// After creating refresh token, limit to 5 devices
const userTokens = await prisma.refreshToken.findMany({
  where: { userId },
  orderBy: { createdAt: 'desc' }
});

// Delete oldest tokens if > 5
if (userTokens.length > 5) {
  await prisma.refreshToken.deleteMany({
    where: {
      id: { in: userTokens.slice(5).map(t => t.id) }
    }
  });
}

Prevention

  • Device management UI - Show logged-in devices
  • Device limit - Max 5-10 devices per user
  • Device naming - Let users name their devices
  • Remote logout - Let users log out other devices

Password Reset Issues

Severity: 🟢 Low

Symptoms

{
  "error": "Bad Request",
  "message": "Password reset link expired or invalid"
}

Common Causes

  1. Link expired - Older than 24 hours
  2. Already used - Link can only be used once
  3. Wrong token - Token doesn't match database

Solutions

Solution 1: Request new reset link

# Via API (if endpoint exists)
curl -X POST http://localhost:4000/api/auth/forgot-password \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'

Solution 2: Manually reset password

# Generate bcrypt hash
docker compose exec api node -e "
const bcrypt = require('bcryptjs');
console.log(bcrypt.hashSync('NewPassword123!', 10));
"

# Update password
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "UPDATE \"User\" SET password = '\$2a\$10\$...' WHERE email = 'user@example.com';"

Prevention

  • Longer expiration - 24-hour expiration is reasonable
  • Clear messaging - Tell users link expires
  • Easy re-request - Simple way to request new link

!!! note "V2 Status" V2 doesn't currently have password reset flow. This section is for future implementation.


Email Not Received

Severity: 🟡 Medium

Symptoms

User requests password reset but doesn't receive email.

Common Causes

  1. Email in spam - Filtered to spam folder
  2. SMTP issue - Email sending failed
  3. Wrong email - Typo in email address
  4. Email delay - Taking long to deliver

Solutions

Solution 1: Check spam folder

  1. Check Spam/Junk folder
  2. Check Promotions tab (Gmail)
  3. Check email filters

Solution 2: Check email logs

# API logs show email sending
docker compose logs api | grep -i "email\|smtp"

# Should show:
# Email sent to user@example.com: Password Reset

Solution 3: Check MailHog (dev mode)

If EMAIL_TEST_MODE=true:

# Open MailHog
http://localhost:8025

# All emails appear here instead of being sent

Solution 4: Test SMTP connection

# Test SMTP via API
curl -X POST http://localhost:4000/api/test-email \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "to": "test@example.com",
    "subject": "Test",
    "text": "Test email"
  }'

Solution 5: Manually reset password

See "Manually reset password" in previous section.

Prevention

  • Email testing - Test email delivery in production
  • Clear from address - Use recognizable sender
  • SPF/DKIM/DMARC - Configure email authentication
  • Resend option - Easy way to resend email

Rate Limiting

Too Many Login Attempts

Severity: 🟡 Medium

Symptoms

{
  "error": "Too Many Requests",
  "message": "Too many login attempts. Please try again later."
}

Common Causes

  1. Too many failed logins - More than 10/minute
  2. Automated attack - Bot trying to brute-force
  3. Shared IP - Multiple users behind same NAT

Solutions

Solution 1: Wait and retry

Rate limit is per IP address:

  • Limit: 10 requests per minute
  • Window: 1 minute
  • Action: Wait 1 minute, then try again

Solution 2: Check Redis rate limit

# Connect to Redis
docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD

# Find rate limit keys
KEYS rl:auth:*

# Check specific IP
GET rl:auth:192.168.1.100

# Shows number of requests in current window

Solution 3: Clear rate limit (admin)

# Delete rate limit key for IP
docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD DEL rl:auth:192.168.1.100

# Or clear all auth rate limits
docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD --scan --pattern "rl:auth:*" | xargs docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD DEL

Solution 4: Adjust rate limit

In api/src/middleware/rate-limit.ts:

export const authRateLimit = rateLimit({
  windowMs: 60 * 1000,  // 1 minute
  max: 10,  // 10 requests per minute
  message: 'Too many login attempts. Please try again later.',
  standardHeaders: true,
  legacyHeaders: false,
});

// Increase to 20/minute:
max: 20,

Solution 5: Use different IP

If behind NAT with many users:

  1. Use VPN
  2. Use mobile network
  3. Contact administrator to whitelist IP

Prevention

  • Reasonable limits - 10/min is reasonable
  • Per-account limit - Also limit by email (not just IP)
  • CAPTCHA - Add CAPTCHA after 3 failed attempts
  • Account lockout - Lock account after 10 failed attempts

Account Temporarily Locked

Severity: 🟡 Medium

Symptoms

{
  "error": "Forbidden",
  "message": "Account temporarily locked due to too many failed login attempts. Please try again in 30 minutes."
}

Solutions

Solution 1: Wait for unlock

Accounts auto-unlock after lockout period (default: 30 minutes).

Solution 2: Manually unlock (admin)

# If lockout implemented in database:
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "UPDATE \"User\" SET \"lockedUntil\" = NULL WHERE email = 'user@example.com';"

Solution 3: Contact administrator

If you're a user:

  1. Contact system administrator
  2. Verify your identity
  3. Request account unlock

Prevention

  • Reasonable threshold - 10 failed attempts is reasonable
  • Automatic unlock - Auto-unlock after time period
  • Email notification - Notify user of lockout
  • Appeal process - Way to appeal false positive

!!! note "V2 Status" V2 doesn't currently have account lockout. This section is for future implementation.


Debugging Auth

Checking JWT Payload

Severity: 🟢 Low (informational)

How to Decode JWT

// In browser console
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzA4MDAwMDAwLCJleHAiOjE3MDgwMDA5MDB9.signature';

// Decode header
const header = JSON.parse(atob(token.split('.')[0]));
console.log('Header:', header);
// { alg: 'HS256', typ: 'JWT' }

// Decode payload
const payload = JSON.parse(atob(token.split('.')[1]));
console.log('Payload:', payload);
// {
//   id: '123',
//   email: 'test@example.com',
//   role: 'USER',
//   iat: 1708000000,  // Issued at (Unix timestamp)
//   exp: 1708000900   // Expires at (Unix timestamp)
// }

// Check expiration
console.log('Issued:', new Date(payload.iat * 1000));
console.log('Expires:', new Date(payload.exp * 1000));
console.log('Is expired:', Date.now() > payload.exp * 1000);

Verifying Refresh Tokens

Check Refresh Token in Database

# Find all refresh tokens for user
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "SELECT rt.id, rt.\"createdAt\", rt.\"expiresAt\", u.email
      FROM \"RefreshToken\" rt
      JOIN \"User\" u ON rt.\"userId\" = u.id
      WHERE u.email = 'user@example.com'
      ORDER BY rt.\"createdAt\" DESC;"

# Output:
# id                                   | createdAt            | expiresAt            | email
# uuid-here                            | 2026-02-10 10:00:00 | 2026-02-17 10:00:00 | user@example.com

# Check if expired:
# SELECT id FROM "RefreshToken" WHERE id = 'uuid' AND "expiresAt" > NOW();

Checking User Status

View User Details

# Full user details
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "SELECT id, email, name, role, \"createdAt\", \"updatedAt\"
      FROM \"User\"
      WHERE email = 'user@example.com';"

# Check active sessions
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
  -c "SELECT COUNT(*) as active_sessions
      FROM \"RefreshToken\" rt
      JOIN \"User\" u ON rt.\"userId\" = u.id
      WHERE u.email = 'user@example.com'
        AND rt.\"expiresAt\" > NOW();"

Authentication Documentation

Other Troubleshooting

Security Resources


Last Updated: February 2026 Version: V2.0 Status: Complete