Security Practices Skill
Comprehensive security patterns for fullstack serverless applications including authentication, authorization, input validation, and common vulnerability prevention.
Core Principles
- •Defense in Depth: Multiple layers of security
- •Least Privilege: Grant minimum necessary permissions
- •Fail Securely: Default to secure state on errors
- •Never Trust User Input: Always validate and sanitize
- •Security by Design: Build security in from the start
Authentication
Session-Based Authentication (Remix)
typescript
// app/lib/auth.server.ts
import { createCookieSessionStorage, redirect } from "@remix-run/node"
import bcrypt from "bcryptjs"
const sessionSecret = process.env.SESSION_SECRET
if (!sessionSecret) throw new Error("SESSION_SECRET must be set")
const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
sameSite: "lax",
secrets: [sessionSecret],
secure: process.env.NODE_ENV === "production",
},
})
export async function createUserSession(userId: string, redirectTo: string) {
const session = await sessionStorage.getSession()
session.set("userId", userId)
return redirect(redirectTo, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session),
},
})
}
export async function getUserSession(request: Request) {
return sessionStorage.getSession(request.headers.get("Cookie"))
}
export async function getUserId(request: Request) {
const session = await getUserSession(request)
const userId = session.get("userId")
if (!userId || typeof userId !== "string") return null
return userId
}
export async function requireUserId(request: Request, redirectTo: string = "/login") {
const userId = await getUserId(request)
if (!userId) {
throw redirect(redirectTo)
}
return userId
}
export async function logout(request: Request) {
const session = await getUserSession(request)
return redirect("/", {
headers: {
"Set-Cookie": await sessionStorage.destroySession(session),
},
})
}
// Password hashing
export async function hashPassword(password: string) {
return bcrypt.hash(password, 10)
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash)
}
JWT-Based Authentication
typescript
// lib/jwt.server.ts
import jwt from "jsonwebtoken"
const JWT_SECRET = process.env.JWT_SECRET
if (!JWT_SECRET) throw new Error("JWT_SECRET must be set")
export interface TokenPayload {
userId: string
email: string
}
export function signToken(payload: TokenPayload): string {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: "7d",
issuer: "your-app",
audience: "your-app-users",
})
}
export function verifyToken(token: string): TokenPayload | null {
try {
const payload = jwt.verify(token, JWT_SECRET, {
issuer: "your-app",
audience: "your-app-users",
})
return payload as TokenPayload
} catch (error) {
return null
}
}
// Refresh token pattern
export function signRefreshToken(userId: string): string {
return jwt.sign({ userId, type: "refresh" }, JWT_SECRET, {
expiresIn: "30d",
})
}
export function verifyRefreshToken(token: string): string | null {
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: string; type: string }
if (payload.type !== "refresh") return null
return payload.userId
} catch (error) {
return null
}
}
OAuth2 Integration
typescript
// app/lib/oauth.server.ts
import { Authenticator } from "remix-auth"
import { OAuth2Strategy } from "remix-auth-oauth2"
import { sessionStorage } from "./auth.server"
export const authenticator = new Authenticator(sessionStorage)
// Google OAuth
authenticator.use(
new OAuth2Strategy(
{
authorizationURL: "https://accounts.google.com/o/oauth2/v2/auth",
tokenURL: "https://oauth2.googleapis.com/token",
clientID: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
callbackURL: "https://yourapp.com/auth/google/callback",
scope: "openid email profile",
},
async ({ accessToken, refreshToken, extraParams, profile }) => {
// Find or create user
const user = await findOrCreateUser({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
})
return user
}
),
"google"
)
Authorization
Role-Based Access Control (RBAC)
typescript
// lib/rbac.ts
export enum Role {
ADMIN = "ADMIN",
MANAGER = "MANAGER",
USER = "USER",
}
export enum Permission {
// User permissions
USER_READ = "user:read",
USER_WRITE = "user:write",
USER_DELETE = "user:delete",
// Content permissions
CONTENT_READ = "content:read",
CONTENT_WRITE = "content:write",
CONTENT_DELETE = "content:delete",
CONTENT_PUBLISH = "content:publish",
}
const rolePermissions: Record<Role, Permission[]> = {
[Role.ADMIN]: [
Permission.USER_READ,
Permission.USER_WRITE,
Permission.USER_DELETE,
Permission.CONTENT_READ,
Permission.CONTENT_WRITE,
Permission.CONTENT_DELETE,
Permission.CONTENT_PUBLISH,
],
[Role.MANAGER]: [
Permission.USER_READ,
Permission.CONTENT_READ,
Permission.CONTENT_WRITE,
Permission.CONTENT_PUBLISH,
],
[Role.USER]: [
Permission.CONTENT_READ,
],
}
export function hasPermission(role: Role, permission: Permission): boolean {
return rolePermissions[role].includes(permission)
}
export function requirePermission(role: Role, permission: Permission) {
if (!hasPermission(role, permission)) {
throw new Error("Insufficient permissions")
}
}
// Middleware for route protection
export async function requireRole(request: Request, allowedRoles: Role[]) {
const user = await getCurrentUser(request)
if (!user || !allowedRoles.includes(user.role)) {
throw new Response("Forbidden", { status: 403 })
}
return user
}
Resource-Level Authorization
typescript
// lib/resource-auth.ts
export async function canAccessResource(
userId: string,
resourceId: string,
action: "read" | "write" | "delete"
): Promise<boolean> {
const resource = await getResource(resourceId)
// Owner can do anything
if (resource.ownerId === userId) return true
// Check shared permissions
const permission = await getResourcePermission(resourceId, userId)
if (!permission) return false
switch (action) {
case "read":
return ["read", "write", "admin"].includes(permission.level)
case "write":
return ["write", "admin"].includes(permission.level)
case "delete":
return permission.level === "admin"
default:
return false
}
}
// Usage in route
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request)
const canAccess = await canAccessResource(userId, params.id, "read")
if (!canAccess) {
throw new Response("Forbidden", { status: 403 })
}
return json(await getResource(params.id))
}
Input Validation
Zod Validation
typescript
import { z } from "zod"
// User registration schema
export const registerSchema = z.object({
email: z.string().email("Invalid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain uppercase letter")
.regex(/[a-z]/, "Password must contain lowercase letter")
.regex(/[0-9]/, "Password must contain number")
.regex(/[^A-Za-z0-9]/, "Password must contain special character"),
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be less than 20 characters")
.regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"),
})
// Sanitize HTML input
import DOMPurify from "isomorphic-dompurify"
export function sanitizeHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
ALLOWED_ATTR: ["href"],
})
}
// SQL injection prevention (use ORM or parameterized queries)
// ❌ NEVER do this
const query = `SELECT * FROM users WHERE email = '${userInput}'`
// ✅ DO this (with Prisma)
const user = await prisma.user.findUnique({
where: { email: userInput },
})
CORS Configuration
typescript
// SST configuration
export default {
config() {
return { name: "my-app", region: "us-east-1" }
},
stacks(app) {
app.stack(function Site({ stack }) {
const api = new Api(stack, "api", {
cors: {
allowCredentials: true,
allowHeaders: ["content-type", "authorization"],
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowOrigins:
app.stage === "prod"
? ["https://yourapp.com"]
: ["http://localhost:3000"],
},
routes: {
"GET /users": "functions/users.handler",
},
})
})
},
}
Rate Limiting
typescript
// lib/rate-limit.ts
import { DynamoDB } from "@aws-sdk/client-dynamodb"
const dynamodb = new DynamoDB({ region: "us-east-1" })
export async function rateLimit(
identifier: string,
limit: number = 100,
window: number = 60 * 1000 // 1 minute
): Promise<boolean> {
const now = Date.now()
const key = `ratelimit:${identifier}:${Math.floor(now / window)}`
try {
await dynamodb.updateItem({
TableName: "RateLimits",
Key: { key: { S: key } },
UpdateExpression: "ADD requests :inc SET #ttl = :ttl",
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: {
":inc": { N: "1" },
":ttl": { N: String(Math.floor((now + window) / 1000)) },
},
ConditionExpression: "attribute_not_exists(requests) OR requests < :limit",
ExpressionAttributeValues: {
...{ ":limit": { N: String(limit) } },
},
})
return true
} catch (error) {
return false // Rate limit exceeded
}
}
// Usage in API route
export async function handler(event: APIGatewayProxyEvent) {
const ip = event.requestContext.identity.sourceIp
const allowed = await rateLimit(ip, 100, 60000)
if (!allowed) {
return {
statusCode: 429,
body: JSON.stringify({ error: "Too many requests" }),
}
}
// Process request
}
Secrets Management with SST
typescript
// sst.config.ts
import { Config } from "sst/constructs"
export default {
config() {
return { name: "my-app", region: "us-east-1" }
},
stacks(app) {
app.stack(function Api({ stack }) {
// Define secrets
const DATABASE_URL = new Config.Secret(stack, "DATABASE_URL")
const JWT_SECRET = new Config.Secret(stack, "JWT_SECRET")
const STRIPE_SECRET_KEY = new Config.Secret(stack, "STRIPE_SECRET_KEY")
const api = new Api(stack, "api", {
bind: [DATABASE_URL, JWT_SECRET, STRIPE_SECRET_KEY],
routes: {
"GET /users": "functions/users.handler",
},
})
})
},
}
// Set secrets
// sst secrets set DATABASE_URL "postgres://..."
// sst secrets set JWT_SECRET "your-secret"
// Access in Lambda
import { Config } from "sst/node/config"
export async function handler() {
const dbUrl = Config.DATABASE_URL
const jwtSecret = Config.JWT_SECRET
// Use secrets
}
Security Headers
typescript
// app/root.tsx (Remix)
export function headers() {
return {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Content-Security-Policy": [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' https://api.yourapp.com",
].join("; "),
}
}
Common Vulnerabilities & Prevention
XSS (Cross-Site Scripting)
typescript
// ❌ Dangerous - renders raw HTML
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// ✅ Safe - React escapes by default
<div>{userInput}</div>
// ✅ Safe - sanitize if you need HTML
import DOMPurify from "isomorphic-dompurify"
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />
CSRF (Cross-Site Request Forgery)
typescript
// Remix protects against CSRF automatically with form submissions
// For API endpoints, use CSRF tokens
import { createCookie } from "@remix-run/node"
const csrfCookie = createCookie("csrf", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60, // 1 hour
})
export async function validateCsrfToken(request: Request) {
const cookie = await csrfCookie.parse(request.headers.get("Cookie"))
const header = request.headers.get("X-CSRF-Token")
if (!cookie || cookie !== header) {
throw new Error("Invalid CSRF token")
}
}
SQL Injection
typescript
// ❌ NEVER concatenate user input into SQL
const users = await db.raw(`SELECT * FROM users WHERE email = '${email}'`)
// ✅ Use parameterized queries or ORM
const users = await db.raw("SELECT * FROM users WHERE email = ?", [email])
// ✅ Best: Use Prisma/Drizzle
const user = await prisma.user.findUnique({ where: { email } })
Best Practices Checklist
- • Authentication: Use secure session management or JWTs
- • Passwords: Hash with bcrypt (10+ rounds), enforce strong password policy
- • Authorization: Implement RBAC or resource-level permissions
- • Input Validation: Validate and sanitize all user input with Zod
- • HTTPS: Always use HTTPS in production
- • CORS: Configure strict CORS policies
- • Rate Limiting: Implement rate limiting on APIs
- • Secrets: Use SST Config.Secret, never commit secrets
- • Security Headers: Set appropriate security headers
- • XSS Prevention: Sanitize HTML, use React's default escaping
- • CSRF Protection: Use CSRF tokens for state-changing operations
- • SQL Injection: Use ORMs or parameterized queries
- • Dependencies: Regularly update dependencies, use
npm audit - • Logging: Log security events, but never log sensitive data
- • Error Messages: Don't expose stack traces or internal details to users
Testing Security
typescript
// Test authentication
test("requires authentication", async () => {
const response = await fetch("/api/protected")
expect(response.status).toBe(401)
})
// Test authorization
test("requires admin role", async () => {
const userToken = await getToken({ role: "USER" })
const response = await fetch("/api/admin", {
headers: { Authorization: `Bearer ${userToken}` },
})
expect(response.status).toBe(403)
})
// Test input validation
test("rejects invalid email", async () => {
const response = await fetch("/api/register", {
method: "POST",
body: JSON.stringify({ email: "invalid", password: "test123" }),
})
expect(response.status).toBe(400)
})
// Test rate limiting
test("rate limits excessive requests", async () => {
const requests = Array.from({ length: 101 }, () =>
fetch("/api/endpoint")
)
const responses = await Promise.all(requests)
const rateLimited = responses.some(r => r.status === 429)
expect(rateLimited).toBe(true)
})