Skip to main content

Authentication System

Sunschool uses JWT (JSON Web Token) authentication with scrypt password hashing. From server/middleware/auth.ts:
import jwt from 'jsonwebtoken';
import { scrypt, randomBytes, timingSafeEqual } from 'crypto';
import { promisify } from 'util';

const scryptAsync = promisify(scrypt);
const JWT_SECRET = process.env.JWT_SECRET || 'sunschool-secure-jwt-secret-for-development';
const JWT_EXPIRES_IN = '7d'; // 7 days

JWT Configuration

JWT tokens provide stateless authentication, allowing the server to verify user identity without session storage.

Token Generation

From server/middleware/auth.ts:
export function generateToken(user: { id: string | number, role: string }): string {
  const payload: JwtPayload = {
    userId: String(user.id),
    role: user.role
  };
  
  try {
    const token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
    return token;
  } catch (error) {
    console.error('Failed to generate token:', error);
    throw error;
  }
}
Configuration:
JWT_SECRET="your-secure-random-secret-min-32-chars"
JWT_EXPIRY=7d  # Optional: 7d, 30d, 1h, etc.
From server/config/env.ts:
export const JWT_SECRET = getEnv('JWT_SECRET', SESSION_SECRET);
export const JWT_EXPIRY = getEnv('JWT_EXPIRY', '7d');

Token Verification

From server/middleware/auth.ts:
export async function authenticateJwt(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
  let token: string | undefined;

  // Try Authorization header first (most common)
  const authHeader = req.headers.authorization;
  if (authHeader) {
    const parts = authHeader.split(' ');
    if (parts.length === 2 && parts[0] === 'Bearer') {
      token = parts[1];
    }
  }

  // Check for custom header (for sunschool.xyz domain)
  if (!token && req.headers['x-sunschool-auth-token']) {
    token = req.headers['x-sunschool-auth-token'] as string;
  }

  // Check cookies
  if (!token && req.cookies && req.cookies.token) {
    token = req.cookies.token;
  }
  
  if (!token) {
    res.status(401).json({ error: 'No authorization token provided' });
    return;
  }
  
  try {
    const payload = verifyToken(token);
    const user = await storage.getUser(payload.userId);
    
    if (!user) {
      res.status(401).json({ error: 'User not found' });
      return;
    }
    
    req.user = user;
    next();
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      res.status(401).json({ error: 'Token expired' });
    } else {
      res.status(401).json({ error: 'Invalid token' });
    }
  }
}

Generate Secure Secrets

Never use default secrets in production. Generate cryptographically secure random values.
Generate JWT secret:
# Using OpenSSL (recommended)
openssl rand -base64 32
# Output: XwK9vL2mN8pQ5rT6uY7zA1bC3dE4fG5hIjK6lM7nO8p

# Using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Output: A1bC2dE3fG4hI5jK6lM7nO8pQ9rS0tU1vW2xY3zA4b

# Using Python
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
Add to .env:
JWT_SECRET="XwK9vL2mN8pQ5rT6uY7zA1bC3dE4fG5hIjK6lM7nO8pQ9rS0tU1vW2xY3zA4b"
SESSION_SECRET="A1bC2dE3fG4hI5jK6lM7nO8pQ9rS0tU1vW2xY3zA4bC5dE6fG7hI8jK9lM0n"
If JWT_SECRET is not set, it defaults to SESSION_SECRET. Set both separately for better security.

Password Security

scrypt Hashing

Sunschool uses scrypt for password hashing - a memory-hard function resistant to brute-force attacks. From server/middleware/auth.ts:
export async function hashPassword(password: string): Promise<string> {
  const salt = randomBytes(16).toString("hex");
  const buf = (await scryptAsync(password, salt, 64)) as Buffer;
  return `${buf.toString("hex")}.${salt}`;
}

export async function comparePasswords(supplied: string, stored: string): Promise<boolean> {
  if (!stored || !stored.includes('.')) {
    console.error('Invalid stored password format');
    return false;
  }
  const [hashed, salt] = stored.split(".");
  const hashedBuf = Buffer.from(hashed, "hex");
  const suppliedBuf = (await scryptAsync(supplied, salt, 64)) as Buffer;
  return timingSafeEqual(hashedBuf, suppliedBuf);
}
Security features:
  • Random 16-byte salt per password
  • 64-byte derived key using scrypt
  • Timing-safe comparison to prevent timing attacks
  • Format: {hash_hex}.{salt_hex}

Password Requirements

Implement password requirements in your client application. The server accepts any non-empty password.
Recommended client-side validation:
function validatePassword(password) {
  return (
    password.length >= 8 &&
    /[A-Z]/.test(password) &&  // Uppercase
    /[a-z]/.test(password) &&  // Lowercase
    /[0-9]/.test(password)     // Digit
  );
}

Role-Based Access Control

From shared/schema.ts:
export const userRoleEnum = pgEnum("user_role", ["ADMIN", "PARENT", "LEARNER"]);

User Roles

Permissions:
  • Full system access
  • View all users and data
  • Manage parents and learners
  • Access admin-only endpoints
  • Export all data
Use case: System administrators, school administratorsFrom server/auth.ts:
// First user auto-promoted to ADMIN
const userCountResult = await db.select({ count: count() }).from(users);
const isFirstUser = userCountResult[0].count === 0;
const effectiveRole = isFirstUser ? "ADMIN" : role;

Role Middleware

From server/middleware/auth.ts:
export function hasRoleMiddleware(roles: string[]) {
  return (req: AuthRequest, res: Response, next: NextFunction): void => {
    if (!req.user) {
      res.status(401).json({ error: 'Unauthorized' });
      return;
    }
    
    if (!roles.includes(req.user.role)) {
      res.status(403).json({ error: 'Forbidden' });
      return;
    }
    
    next();
  };
}
Usage in routes:
// Admin-only endpoint
app.get("/api/parents", hasRole(["ADMIN"]), asyncHandler(async (req, res) => {
  const parents = await storage.getAllParents();
  res.json(parents);
}));

// Parent or Admin
app.get("/api/learners", hasRole(["PARENT", "ADMIN"]), asyncHandler(async (req, res) => {
  // ...
}));

// Any authenticated user
app.get("/api/lessons/active", isAuthenticated, asyncHandler(async (req, res) => {
  // ...
}));

First User Admin Promotion

From server/auth.ts and server/routes.ts:
// Check if this is the first user being registered
const userCountResult = await db.select({ count: count() }).from(users);
const isFirstUser = userCountResult[0].count === 0;

// If this is the first user, make them an admin regardless of requested role
const effectiveRole = isFirstUser ? "ADMIN" : role;

const user = await storage.createUser({
  username,
  email,
  name,
  role: effectiveRole,
  password: hashedPassword,
});
First registered user becomes ADMIN automatically. This ensures you can bootstrap the system without manual database access.
Registration response includes promotion notice:
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": {
    "id": 1,
    "username": "admin",
    "role": "ADMIN"
  },
  "wasPromotedToAdmin": true
}

Session Security

From server/config/env.ts:
export const SESSION_SECRET = getEnv('SESSION_SECRET', 'dev-secret-change-me');
Session configuration:
SESSION_SECRET="your-session-secret-different-from-jwt"
Use different secrets for JWT and sessions to limit the impact of a potential key compromise.

Session Storage

From shared/schema.ts, sessions are stored in PostgreSQL:
export const sessions = pgTable(
  "sessions",
  {
    sid: varchar("sid").primaryKey(),
    sess: jsonb("sess").notNull(),
    expire: timestamp("expire").notNull(),
  },
  (table) => [index("IDX_session_expire").on(table.expire)],
);
Automatic cleanup: Expired sessions are periodically removed via the expire index.

CORS Configuration

From server/auth.ts:
const origin = req.headers.origin || req.headers.referer || 'unknown';
let isSunschool = false;
try {
  const parsedOrigin = new URL(origin);
  isSunschool = parsedOrigin.hostname === 'sunschool.xyz' || 
                parsedOrigin.hostname.endsWith('.sunschool.xyz');
} catch (_e) { /* ignore invalid origin URL */ }

if (isSunschool) {
  res.header('Access-Control-Allow-Origin', origin);
  res.header('Access-Control-Allow-Credentials', 'true');
  res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS');
  res.header('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Sunschool-Auth,X-Sunschool-Auth-Token');
}
SAST finding (MEDIUM priority): CORS origin uses substring match. For production, tighten to exact domain match.
Recommended production CORS:
const allowedOrigins = [
  'https://sunschool.xyz',
  'https://www.sunschool.xyz',
  'https://app.sunschool.xyz'
];

if (allowedOrigins.includes(origin)) {
  res.header('Access-Control-Allow-Origin', origin);
  res.header('Access-Control-Allow-Credentials', 'true');
}

API Security Best Practices

Rate Limiting

Not implemented by default. Add rate limiting for production deployments.
Recommended: express-rate-limit
npm install express-rate-limit
import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  message: 'Too many requests from this IP'
});

app.use('/api/', limiter);

Input Validation

Current validation:
if (!username || !password) {
  return res.status(400).json({ error: "Username and password are required" });
}

if (!gradeLevel || !learnerId) {
  return res.status(400).json({ error: "Missing required fields" });
}
Recommended: Add schema validation with Zod
npm install zod
import { z } from 'zod';

const loginSchema = z.object({
  username: z.string().min(3).max(50),
  password: z.string().min(8)
});

try {
  const { username, password } = loginSchema.parse(req.body);
} catch (error) {
  return res.status(400).json({ error: error.errors });
}

SQL Injection Protection

Already protected. Sunschool uses Drizzle ORM with parameterized queries.
From server/routes.ts:
// Safe: Uses parameterized query via Drizzle
const user = await storage.getUserByUsername(username);

// Safe: Uses prepared statements
const result = await db.select().from(users).where(eq(users.id, userId));
Avoid raw SQL unless necessary:
// Unsafe
const query = `SELECT * FROM users WHERE username = '${username}'`;

// Safe (if raw SQL needed)
const query = await pool.query(
  'SELECT * FROM users WHERE username = $1',
  [username]
);

XSS Protection

From ENGINEERING.md:
SVG content is sanitized via a lightweight regex-based sanitizer in server/services/svg-llm-service.ts
SVG sanitization:
  • Strips <script>, <style>, <iframe> tags
  • Removes event handlers (onclick, onload, etc.)
  • Blocks javascript: and data: URIs
  • LLM prompts prohibit scripts and external references
SAST finding (LOW priority): SVG innerHTML rendering. Server-side DOMPurify mitigates; consider client-side pass.

Environment-Specific Security

Development

NODE_ENV=development
DATABASE_SSL=false
JWT_SECRET="dev-jwt-secret-not-for-production"
SESSION_SECRET="dev-session-secret"
Relaxed settings:
  • Detailed error messages
  • No SSL for local database
  • Shorter JWT expiry for testing

Production

NODE_ENV=production
DATABASE_SSL=true
JWT_SECRET="<32+ char secure random string>"
SESSION_SECRET="<different 32+ char secure string>"
JWT_EXPIRY=7d
Hardened settings:
  • Generic error messages
  • SSL required for all connections
  • Long random secrets
  • Extended token expiry for user convenience

Security Checklist

1

Generate Secure Secrets

openssl rand -base64 32  # JWT_SECRET
openssl rand -base64 32  # SESSION_SECRET
2

Enable SSL for Database

DATABASE_URL="postgresql://...?sslmode=require"
DATABASE_SSL=true
3

Set Strong Password Policy

Implement client-side validation:
  • Minimum 8 characters
  • Mixed case, numbers, symbols
4

Configure CORS Properly

Use exact origin matching, not substrings
5

Add Rate Limiting

Install and configure express-rate-limit
6

Set NODE_ENV=production

NODE_ENV=production
7

Keep Dependencies Updated

npm audit
npm audit fix

Audit Logging

Not implemented by default. Add audit logging for production.
Recommended approach:
// Create audit_logs table
export const auditLogs = pgTable("audit_logs", {
  id: uuid("id").defaultRandom().primaryKey(),
  userId: integer("user_id").references(() => users.id),
  action: text("action").notNull(),
  resource: text("resource"),
  details: jsonb("details"),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  createdAt: timestamp("created_at").defaultNow(),
});

// Log critical actions
await db.insert(auditLogs).values({
  userId: req.user.id,
  action: 'LOGIN',
  ipAddress: req.ip,
  userAgent: req.headers['user-agent']
});

Next Steps