Skip to main content
This guide covers security best practices for deploying RestAI in production, including critical fixes from the Phase A security audit and ongoing hardening recommendations.
RestAI underwent a comprehensive security audit on 2026-02-09 that identified 124 security issues (18 critical, 29 high, 44 medium, 33 low). Phase A addressed the 9 most critical blockers that would have prevented safe production deployment. Review PHASE-A-CHANGELOG.md for complete details.

Critical Security Fixes (Phase A)

These vulnerabilities have been fixed in the current version. Ensure you’re running the latest code:

1. JWT Secrets Hardcoded

Severity: CRITICAL
File: apps/api/src/lib/jwt.ts:23-28
The vulnerability: Previous versions used hardcoded fallback secrets:
// VULNERABLE CODE (fixed)
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret-change-me";
If deployed without environment variables, the API would silently use public default secrets. Attackers could forge tokens and impersonate any user. Current fix:
if (!process.env.JWT_SECRET || !process.env.JWT_REFRESH_SECRET) {
  throw new Error("JWT_SECRET and JWT_REFRESH_SECRET environment variables are required");
}
const JWT_SECRET: string = process.env.JWT_SECRET;
const JWT_REFRESH_SECRET: string = process.env.JWT_REFRESH_SECRET;
The server now refuses to start without proper secrets configured. Action required:
1

Generate strong secrets

# Generate two different secrets
openssl rand -base64 48
openssl rand -base64 48
2

Add to .env file

JWT_SECRET=<first-generated-secret>
JWT_REFRESH_SECRET=<second-generated-secret>
Never use the same value for both secrets.
3

Verify startup

docker-compose up api
# Should start successfully without errors

2. Access Token TTL Too Long

Severity: CRITICAL
File: apps/api/src/lib/jwt.ts:49
The vulnerability: Access tokens previously had an 8-hour expiration. A stolen token (via XSS, network interception, or physical access) remained valid for 8 hours even after the user logged out. Current fix: Access tokens now expire in 15 minutes:
return sign({ ...payload, iat: now, exp: now + 15 * 60 }, JWT_SECRET);
The frontend automatically refreshes tokens using the refresh token (7-day expiry). Impact: Reduces attack window from 8 hours to 15 minutes for compromised tokens.

3. CORS Misconfiguration

Severity: HIGH
File: apps/api/src/app.ts:72-75
The vulnerability: CORS was hardcoded to http://localhost:3000, making production deployments either:
  1. Completely broken (all frontend requests blocked)
  2. Insecurely opened with origin: "*" (any site can make authenticated requests)
Current fix:
const CORS_ORIGINS = process.env.CORS_ORIGINS
  ? process.env.CORS_ORIGINS.split(",")
  : ["http://localhost:3000"];
app.use("*", cors({ origin: CORS_ORIGINS, credentials: true }));
Action required: Set CORS_ORIGINS in production .env:
# Single domain
CORS_ORIGINS=https://app.yourrestaurant.com

# Multiple domains (no spaces after commas)
CORS_ORIGINS=https://app.yourrestaurant.com,https://staging.yourrestaurant.com
Never use CORS_ORIGINS=* in production. This allows any malicious website to make authenticated requests on behalf of your users.

4. Session Token Leak in Public Endpoint

Severity: CRITICAL
File: apps/api/src/routes/customer.ts (GET /:branchSlug/:tableCode/check-session)
The vulnerability: The public endpoint check-session returned the full customer JWT token:
// VULNERABLE CODE (fixed)
return c.json({
  success: true,
  data: {
    sessionId: activeSession.id,
    customerName: activeSession.customer_name,
    token: activeSession.token,  // ← Full JWT exposed
  },
});
Anyone with a table QR code could call this endpoint and steal the JWT of any customer seated at that table, then:
  • Place orders as that customer
  • Call waiters
  • Request the bill
  • Access loyalty program data
  • Redeem rewards
Current fix: The token field is removed from both the database query and response:
const [activeSession] = await db.select({
  id: schema.tableSessions.id,
  status: schema.tableSessions.status,
  customer_name: schema.tableSessions.customer_name,
  // token field removed
}).from(schema.tableSessions)...

return c.json({
  success: true,
  data: {
    hasSession: true,
    status: "active",
    sessionId: activeSession.id,
    customerName: activeSession.customer_name,
    // token removed from response
  },
});
Tokens are now only returned when the customer creates their own session.

5. WebSocket Room Hijacking

Severity: CRITICAL
File: apps/api/src/ws/handlers.ts:166-175
The vulnerability: Authenticated WebSocket clients could manually join any room:
// VULNERABLE CODE (fixed)
case "join": {
  if (data.room) {
    await manager.joinRoom(clientId, data.room);
    ws.send(JSON.stringify({ type: "joined", room: data.room }));
  }
  break;
}
A competitor could:
  1. Create a staff account at their own restaurant
  2. Authenticate via WebSocket
  3. Send {"type":"join","room":"branch:COMPETITOR-UUID"}
  4. Receive real-time updates of competitor’s orders, customers, sales
Current fix: Rooms are assigned automatically during authentication based on JWT claims:
case "join": {
  ws.send(JSON.stringify({ 
    type: "error", 
    message: "Rooms are assigned automatically on auth" 
  }));
  break;
}
case "leave": {
  ws.send(JSON.stringify({ 
    type: "error", 
    message: "Room management is automatic" 
  }));
  break;
}
Customers are auto-joined to their branch and table. Staff are auto-joined only to branches assigned in their JWT.

6. Hardcoded localhost URLs in Customer Flow

Severity: CRITICAL
Files: 5 customer-facing pages (cart, menu, status, etc.)
The vulnerability: Customer pages had hardcoded API URLs:
// VULNERABLE CODE (fixed)
const res = await fetch("http://localhost:3001/api/customer/...");
const wsUrl = "ws://localhost:3001/ws";
In production, customers scanning QR codes would:
  • See an empty menu (fetch failed)
  • Couldn’t add items to cart
  • Couldn’t place orders
  • Couldn’t call waiters or request bills
  • WebSocket never connected
Current fix: All URLs now use environment variables:
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
const res = await fetch(`${API_URL}/api/customer/...`);
Action required: Set build-time environment variables in Dockerfile.web or CI/CD:
NEXT_PUBLIC_API_URL=https://api.yourrestaurant.com
NEXT_PUBLIC_WS_URL=wss://api.yourrestaurant.com

7. No Error Boundaries

Severity: CRITICAL (UX/Reliability)
Files: apps/web/src/app/global-error.tsx, (dashboard)/error.tsx, (customer)/error.tsx
The vulnerability: Any unhandled JavaScript error caused a blank white screen with no recovery mechanism. Users had to manually reload the page. Current fix: Three levels of error boundaries:
  1. global-error.tsx: Catches root-level errors (layout failures)
  2. (dashboard)/error.tsx: Catches dashboard errors (sidebar remains functional)
  3. (customer)/error.tsx: Catches customer flow errors
All boundaries show a friendly message with a retry button.

8. Customer Store Lost Context on Reload

Severity: CRITICAL (UX)
File: apps/web/src/stores/customer-store.ts
The vulnerability: Customer store only persisted token and sessionId to sessionStorage. On page reload:
  • branchSlug and tableCode became null
  • Navigation links broke (/${null}/${null}/profile)
  • Restaurant name disappeared from header
Current fix: All 7 fields are now persisted and restored:
export const useCustomerStore = create<CustomerState>((set) => ({
  token: getItem("customer_token"),
  sessionId: getItem("customer_session_id"),
  branchSlug: getItem("customer_branch_slug"),        // Now persisted
  tableCode: getItem("customer_table_code"),          // Now persisted
  orderId: getItem("customer_order_id"),
  branchName: getItem("customer_branch_name"),        // Now persisted
  customerName: getItem("customer_name"),
  // ...
}));
Page refreshes now maintain complete customer context.

9. .env.example Updated

Severity: LOW
File: .env.example
All environment variables are now documented with secure defaults and clear comments. See Environment Variables for complete reference.

Security Hardening Checklist

Before deploying to production:
1

Secrets and credentials

  • JWT_SECRET is a strong random value (48+ characters)
  • JWT_REFRESH_SECRET is different from JWT_SECRET
  • POSTGRES_PASSWORD is changed from default
  • Secrets are stored in secure vault (not committed to git)
  • .env file is added to .gitignore
2

CORS configuration

  • CORS_ORIGINS is set to your production domain(s) only
  • Multiple origins are comma-separated with no spaces
  • All origins use HTTPS (not HTTP)
  • Wildcard * is not used
3

SSL/TLS

  • Frontend served over HTTPS
  • API served over HTTPS (or behind HTTPS reverse proxy)
  • WebSocket uses wss:// (secure WebSocket)
  • External database connections use ?sslmode=require
  • SSL certificates are valid and auto-renewing (Let’s Encrypt)
4

Database security

  • PostgreSQL not exposed to public internet
  • Strong database password set
  • Backups are encrypted
  • Database user has minimal required privileges
  • Connection pooling configured appropriately
5

Container security

  • Docker images built with --no-cache in CI/CD
  • Containers run as non-root user (UID 1001)
  • Health checks configured for all services
  • Resource limits set (memory, CPU)
  • Unnecessary packages removed from images
6

Network security

  • API firewall rules allow only necessary ports
  • Redis not exposed to public internet
  • Rate limiting configured (see below)
  • DDoS protection enabled (Cloudflare, AWS Shield)
7

Logging and monitoring

  • LOG_LEVEL set to warn or error in production
  • Failed login attempts logged
  • Sensitive data (passwords, tokens) not logged
  • Log aggregation configured (ELK, CloudWatch, etc.)
  • Alerts configured for error spikes
8

Data protection

  • Cloudflare R2 bucket not publicly accessible
  • File upload size limits enforced (16MB max)
  • User input sanitized before database insertion
  • SQL injection protected by Drizzle ORM parameterized queries
  • XSS protection enabled in headers

Additional Security Measures

Rate Limiting

Implement rate limiting to prevent abuse:
import { Context, Next } from "hono";
import { redis } from "../lib/redis";

export async function rateLimit(c: Context, next: Next) {
  const ip = c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || "unknown";
  const key = `rate_limit:${ip}`;
  
  const requests = await redis.incr(key);
  if (requests === 1) {
    await redis.expire(key, 60); // 1 minute window
  }
  
  if (requests > 100) { // 100 requests per minute
    return c.json({ error: "Too many requests" }, 429);
  }
  
  await next();
}

Security Headers

Add security headers to prevent common attacks:
import { secureHeaders } from "hono/secure-headers";

app.use("*", secureHeaders({
  contentSecurityPolicy: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'unsafe-inline'"],
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", "data:", "https:"],
  },
  strictTransportSecurity: "max-age=31536000; includeSubDomains",
  xFrameOptions: "DENY",
  xContentTypeOptions: "nosniff",
}));

Input Validation

Always validate user input:
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

app.post("/auth/login", zValidator("json", loginSchema), async (c) => {
  const { email, password } = c.req.valid("json");
  // ... authentication logic
});

SQL Injection Prevention

Drizzle ORM uses parameterized queries by default:
// SAFE - Drizzle parameterizes automatically
const user = await db.select()
  .from(schema.users)
  .where(eq(schema.users.email, userEmail));

// UNSAFE - Never use raw string concatenation
// const user = await db.execute(`SELECT * FROM users WHERE email = '${userEmail}'`);

File Upload Security

Validate file uploads:
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

app.post("/upload", async (c) => {
  const body = await c.req.parseBody();
  const file = body["file"] as File;
  
  if (!ALLOWED_MIME_TYPES.includes(file.type)) {
    return c.json({ error: "Invalid file type" }, 400);
  }
  
  if (file.size > MAX_FILE_SIZE) {
    return c.json({ error: "File too large" }, 400);
  }
  
  // ... upload to R2
});

Security Monitoring

Failed Authentication Attempts

Log and alert on suspicious activity:
app.post("/auth/login", async (c) => {
  const { email, password } = await c.req.json();
  const user = await authenticateUser(email, password);
  
  if (!user) {
    logger.warn("Failed login attempt", { 
      email, 
      ip: c.req.header("x-forwarded-for"),
      timestamp: new Date().toISOString(),
    });
    
    // Increment failed attempts counter
    const key = `failed_login:${email}`;
    const attempts = await redis.incr(key);
    await redis.expire(key, 900); // 15 minutes
    
    if (attempts > 5) {
      // Lock account or require CAPTCHA
      logger.error("Multiple failed login attempts", { email, attempts });
    }
    
    return c.json({ error: "Invalid credentials" }, 401);
  }
  
  // ... successful login
});

Audit Logging

Log sensitive operations:
const auditLog = async (action: string, userId: string, details: object) => {
  await db.insert(schema.auditLogs).values({
    action,
    user_id: userId,
    details: JSON.stringify(details),
    ip_address: c.req.header("x-forwarded-for"),
    timestamp: new Date(),
  });
};

// Usage
app.delete("/items/:id", requireAuth, async (c) => {
  const itemId = c.req.param("id");
  await db.delete(schema.items).where(eq(schema.items.id, itemId));
  
  await auditLog("item_deleted", c.get("userId"), { itemId });
  
  return c.json({ success: true });
});

Incident Response

If you discover a security breach:
1

Contain the breach

  1. Immediately rotate all JWT secrets:
    # Generate new secrets
    NEW_JWT_SECRET=$(openssl rand -base64 48)
    NEW_JWT_REFRESH_SECRET=$(openssl rand -base64 48)
    
    # Update .env and restart
    docker-compose down
    # Update .env with new secrets
    docker-compose up -d
    
  2. Invalidate all active sessions:
    docker-compose exec redis redis-cli FLUSHALL
    
  3. Force all users to log in again
2

Assess the damage

  1. Check audit logs for unauthorized access
  2. Review database for modified/deleted data
  3. Examine server logs for attack patterns
  4. Identify compromised accounts
3

Patch the vulnerability

  1. Identify the attack vector
  2. Apply security patches
  3. Deploy updated code
  4. Verify fix in staging environment
4

Notify stakeholders

  1. Inform affected users
  2. Document the incident
  3. Update security policies
  4. Implement additional monitoring

Security Resources

Regular Security Audits

Schedule periodic security reviews:
  • Weekly: Review failed login attempts and error logs
  • Monthly: Update dependencies (bun update)
  • Quarterly: Full security audit (automated scanning + manual review)
  • Annually: Third-party penetration testing
The Phase A audit covered 9 critical issues. Phases B-D will address the remaining 115 findings (29 high, 44 medium, 33 low priority). Stay tuned for updates.

Build docs developers (and LLMs) love