Optare v1.0 is now available. Get started →
Security
Security Best Practices

Security Best Practices

Security is critical for any authentication system. This guide covers best practices for keeping your Optare SSO implementation secure.

Environment & Configuration

Secure Environment Variables

# ✅ Good - Use strong, random secrets
BETTER_AUTH_SECRET="$(openssl rand -base64 32)"
 
# ❌ Bad - Weak or predictable secrets
BETTER_AUTH_SECRET="mysecret123"

Best Practices:

  • Generate secrets with cryptographically secure random generators
  • Use at least 32 bytes (256 bits) for secrets
  • Never commit secrets to version control
  • Rotate secrets periodically (every 90 days)
  • Use different secrets for development, staging, and production

Environment Variable Management

# Use a .env file for local development
.env
 
# Add to .gitignore
echo ".env" >> .gitignore
echo ".env.local" >> .gitignore
 
# For production, use platform environment variables
# Vercel: Settings → Environment Variables
# Docker: Use --env-file or secrets management

Authentication Security

Password Requirements

Enforce strong password policies:

import { z } from "zod";
 
export const passwordSchema = z
  .string()
  .min(12, "Password must be at least 12 characters")
  .regex(/[A-Z]/, "Password must contain an uppercase letter")
  .regex(/[a-z]/, "Password must contain a lowercase letter")
  .regex(/[0-9]/, "Password must contain a number")
  .regex(/[^A-Za-z0-9]/, "Password must contain a special character");
 
// In your signup/password change handler
const validation = passwordSchema.safeParse(password);
if (!validation.success) {
  return json({ error: validation.error.errors[0].message }, { status: 400 });
}

Password Hashing

Optare uses bcrypt for password hashing (via Better Auth). Key points:

  • Never store plain-text passwords
  • Use appropriate cost factor (default is 10)
  • Let Better Auth handle hashing - Don't implement your own

Two-Factor Authentication

Enable 2FA for all users, especially admins:

// Force 2FA for admin users
if (user.role === "admin" && !user.twoFactorEnabled) {
  return redirect("/settings/security/enable-2fa");
}

Best Practices:

  • Require 2FA for organization owners and admins
  • Offer backup codes for account recovery
  • Support TOTP (Google Authenticator, Authy)
  • Consider SMS as a backup (but TOTP is preferred)

Session Security

Session Configuration

export const auth = betterAuth({
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24, // Refresh after 1 day
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // 5 minutes
    },
  },
});

Best Practices:

  • Set reasonable session expiration (7-30 days)
  • Use short-lived access tokens (15 minutes to 1 hour)
  • Implement refresh tokens for mobile apps
  • Clear sessions on password change

Cookie Security

// Better Auth automatically sets these, but verify:
// - httpOnly: true (prevent XSS)
// - secure: true (HTTPS only)
// - sameSite: "lax" (CSRF protection)

Session Revocation

Implement forced logout on security events:

import { db } from "~/lib/db";
import { users, sessions } from "~/lib/db/schema";
import { eq } from "drizzle-orm";
 
export async function revokeAllSessions(userId: string) {
  // Delete all active sessions
  await db.delete(sessions).where(eq(sessions.userId, userId));
  
  // Increment session version to invalidate any cached sessions
  await db
    .update(users)
    .set({ sessionVersion: sql`session_version + 1` })
    .where(eq(users.id, userId));
}
 
// Call this on:
// - Password change
// - 2FA disable
// - Account compromise detection

Input Validation

Server-Side Validation

Always validate on the server, even if you validate on the client:

import { z } from "zod";
 
const inviteSchema = z.object({
  email: z.string().email().toLowerCase().trim(),
  role: z.enum(["owner", "admin", "member"]),
});
 
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const data = Object.fromEntries(formData);
  
  // Validate
  const validation = inviteSchema.safeParse(data);
  if (!validation.success) {
    return json({ error: validation.error.errors[0].message }, { status: 400 });
  }
  
  const { email, role } = validation.data;
  // ... proceed with validated data
}

Sanitize User Input

// ✅ Good - Sanitize before storing
const sanitizedName = name.trim().substring(0, 255);
 
// ❌ Bad - Store raw input
await db.update(users).set({ name: userInput });

Prevent SQL Injection

Drizzle ORM protects against SQL injection by default, but be careful with raw queries:

// ✅ Good - Parameterized query
await db.select().from(users).where(eq(users.email, userEmail));
 
// ❌ Bad - String concatenation
await db.execute(sql`SELECT * FROM users WHERE email = '${userEmail}'`);
 
// ✅ Good - If you must use raw SQL, use parameters
await db.execute(sql`SELECT * FROM users WHERE email = ${userEmail}`);

API Security

Rate Limiting

Protect against brute force attacks:

import { RateLimiter } from "~/lib/rate-limiter";
 
const loginLimiter = new RateLimiter({
  points: 5, // 5 attempts
  duration: 15 * 60, // 15 minutes
});
 
export async function action({ request }: ActionFunctionArgs) {
  const ip = request.headers.get("x-forwarded-for") || "unknown";
  
  try {
    await loginLimiter.consume(ip);
  } catch {
    return json({ error: "Too many attempts. Try again in 15 minutes." }, { status: 429 });
  }
  
  // Proceed with login...
}

API Key Security

// ✅ Good - Hash API keys
import crypto from "crypto";
 
const apiKey = `sk_${crypto.randomBytes(32).toString("hex")}`;
const keyHash = crypto.createHash("sha256").update(apiKey).digest("hex");
 
// Store only the hash
await db.insert(apiKeys).values({
  keyHash,
  keyPrefix: apiKey.substring(0, 8), // For identification
});
 
// Return the full key once (never again!)
return { apiKey };

CORS Configuration

// In your server configuration
export const corsHeaders = {
  "Access-Control-Allow-Origin": process.env.ALLOWED_ORIGINS || "*",
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
  "Access-Control-Max-Age": "86400", // 24 hours
};
 
// ❌ Bad - Allow all origins in production
"Access-Control-Allow-Origin": "*"
 
// ✅ Good - Whitelist specific origins
"Access-Control-Allow-Origin": "https://your-app.com"

OAuth 2.0 Security

PKCE for Public Clients

Always use PKCE for mobile and SPA apps:

// Mobile app OAuth flow
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
 
// Authorization request
const authUrl = `${AUTH_URL}/oauth/authorize?` +
  `client_id=${CLIENT_ID}&` +
  `redirect_uri=${REDIRECT_URI}&` +
  `code_challenge=${codeChallenge}&` +
  `code_challenge_method=S256&` +
  `response_type=code`;
 
// Token request
const token = await fetch(`${AUTH_URL}/oauth/token`, {
  method: "POST",
  body: JSON.stringify({
    code,
    code_verifier: codeVerifier,
  }),
});

Redirect URI Validation

// ✅ Good - Exact match
const allowedRedirectUris = client.redirectUris || [];
if (!allowedRedirectUris.includes(redirectUri)) {
  throw new Error("Invalid redirect_uri");
}
 
// ❌ Bad - Pattern matching
if (redirectUri.startsWith("https://")) {
  // Too permissive!
}

State Parameter

Always use and verify the state parameter:

// Generate random state
const state = crypto.randomBytes(32).toString("hex");
sessionStorage.setItem("oauth_state", state);
 
// In callback, verify it matches
const savedState = sessionStorage.getItem("oauth_state");
if (state !== savedState) {
  throw new Error("Invalid state parameter - possible CSRF attack");
}

Data Protection

Encrypt Sensitive Data

import crypto from "crypto";
 
const algorithm = "aes-256-gcm";
const key = Buffer.from(process.env.ENCRYPTION_KEY!, "hex");
 
export function encrypt(text: string): string {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv(algorithm, key, iv);
  
  let encrypted = cipher.update(text, "utf8", "hex");
  encrypted += cipher.final("hex");
  
  const authTag = cipher.getAuthTag();
  
  return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
 
export function decrypt(encrypted: string): string {
  const [ivHex, authTagHex, encryptedText] = encrypted.split(":");
  const iv = Buffer.from(ivHex, "hex");
  const authTag = Buffer.from(authTagHex, "hex");
  
  const decipher = crypto.createDecipheriv(algorithm, key, iv);
  decipher.setAuthTag(authTag);
  
  let decrypted = decipher.update(encryptedText, "hex", "utf8");
  decrypted += decipher.final("utf8");
  
  return decrypted;
}
 
// Use for OAuth tokens, API keys, etc.
const encryptedToken = encrypt(refreshToken);
await db.insert(tokens).values({ token: encryptedToken });

PII Handling

  • Minimize PII collection - Only collect what you need
  • Encrypt at rest - Encrypt sensitive fields in database
  • Encrypt in transit - Always use HTTPS
  • Audit access - Log who accesses PII
  • Implement data deletion - GDPR right to be forgotten

Audit Logging

Log all security-relevant events:

import { db } from "~/lib/db";
import { auditLogs } from "~/lib/db/schema";
 
export async function logSecurityEvent(
  organizationId: string,
  userId: string,
  action: string,
  metadata: any,
  request: Request
) {
  await db.insert(auditLogs).values({
    id: crypto.randomUUID(),
    organizationId,
    userId,
    action,
    resource: "security",
    ipAddress: request.headers.get("x-forwarded-for"),
    userAgent: request.headers.get("user-agent"),
    metadata,
  });
}
 
// Log important events
await logSecurityEvent(orgId, userId, "password.changed", {}, request);
await logSecurityEvent(orgId, userId, "2fa.disabled", {}, request);
await logSecurityEvent(orgId, userId, "api_key.created", { keyId }, request);

Security Headers

Set security headers in your responses:

export function securityHeaders() {
  return {
    "X-Content-Type-Options": "nosniff",
    "X-Frame-Options": "DENY",
    "X-XSS-Protection": "1; mode=block",
    "Referrer-Policy": "strict-origin-when-cross-origin",
    "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
    "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
    "Content-Security-Policy": 
      "default-src 'self'; " +
      "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
      "style-src 'self' 'unsafe-inline'; " +
      "img-src 'self' data: https:; " +
      "font-src 'self' data:; " +
      "connect-src 'self' https://api.optare.one;",
  };
}
 
// In your root loader
export function loader() {
  return json({}, { headers: securityHeaders() });
}

Dependency Security

Regular Updates

# Check for vulnerabilities
pnpm audit
 
# Update dependencies
pnpm update
 
# Check for outdated packages
pnpm outdated

Automated Security Scanning

Add to GitHub Actions:

name: Security Scan
on: [push]
jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Run Snyk
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

Incident Response

Security Incident Checklist

  1. Detect - Monitor logs, set up alerts
  2. Contain - Revoke compromised credentials
  3. Investigate - Review audit logs
  4. Eradicate - Fix vulnerability
  5. Recover - Restore normal operations
  6. Learn - Document and improve

Automated Alerts

// Set up alerts for suspicious activity
export async function detectSuspiciousActivity(userId: string) {
  const recentLogins = await db
    .select()
    .from(sessions)
    .where(eq(sessions.userId, userId))
    .orderBy(desc(sessions.createdAt))
    .limit(10);
 
  // Check for login from new country
  const countries = new Set(recentLogins.map((s) => getCountryFromIP(s.ipAddress)));
  if (countries.size > 3) {
    await sendSecurityAlert(userId, "Multiple countries detected");
  }
 
  // Check for rapid-fire login attempts
  const timestamps = recentLogins.map((s) => s.createdAt.getTime());
  const timeDiffs = timestamps.slice(1).map((t, i) => timestamps[i] - t);
  if (timeDiffs.some((diff) => diff < 1000)) {
    await sendSecurityAlert(userId, "Rapid login attempts");
  }
}

Security Checklist

Pre-Production

  • All secrets use strong random values (32+ bytes)
  • HTTPS enforced everywhere
  • Security headers configured
  • Rate limiting implemented
  • Input validation on all endpoints
  • SQL injection prevention verified
  • XSS protection enabled
  • CSRF tokens in use
  • Password requirements enforced
  • 2FA available and encouraged
  • Session timeouts configured
  • Audit logging implemented
  • Error messages don't leak sensitive info
  • Dependencies scanned for vulnerabilities

Post-Production

  • Security monitoring enabled
  • Incident response plan documented
  • Regular security audits scheduled
  • Penetration testing completed
  • Compliance requirements met (SOC 2, GDPR, etc.)
  • Bug bounty program considered

Next: Rate Limiting →