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 managementAuthentication 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 detectionInput 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 outdatedAutomated 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
- Detect - Monitor logs, set up alerts
- Contain - Revoke compromised credentials
- Investigate - Review audit logs
- Eradicate - Fix vulnerability
- Recover - Restore normal operations
- 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 →