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;
Permissions:
- Manage own children (learners)
- View children’s progress and lessons
- Create/delete learner accounts
- Configure rewards and goals
- Export own family data
Restrictions:
- Cannot access other families’ data
- Cannot promote users to admin
From server/routes.ts:// Parents scoped to their children
if (req.user?.role === "PARENT") {
const children = await storage.getUsersByParentId(req.user.id);
if (!children.some(child => child.id.toString() === learnerId.toString())) {
return res.status(403).json({ error: "Forbidden" });
}
}
Permissions:
- Access own lessons and quizzes
- View own progress and achievements
- Redeem rewards (with parent approval)
- Update own profile (limited fields)
Restrictions:
- Cannot access other learners’ data
- Cannot manage users
- Cannot configure system settings
Note: Learners typically don’t have passwords (parent-managed accounts).
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) => {
// ...
}));
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);
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
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
Generate Secure Secrets
openssl rand -base64 32 # JWT_SECRET
openssl rand -base64 32 # SESSION_SECRET
Enable SSL for Database
DATABASE_URL="postgresql://...?sslmode=require"
DATABASE_SSL=true
Set Strong Password Policy
Implement client-side validation:
- Minimum 8 characters
- Mixed case, numbers, symbols
Configure CORS Properly
Use exact origin matching, not substrings
Add Rate Limiting
Install and configure express-rate-limit
Keep Dependencies Updated
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