security best practices - nself-org/nchat GitHub Wiki
Last Updated: January 31, 2026 Version: 1.0.0
- Authentication & Authorization
- Input Validation
- API Security
- Database Security
- File Upload Security
- Frontend Security
- Secrets Management
- Error Handling
- Testing Security
- Code Review Guidelines
Always use middleware for protected routes:
// Good: Composable middleware pattern
export const POST = compose(
withErrorHandler,
withRateLimit({ limit: 10, window: 60 }),
withAuth,
withAdmin
)(handler)Validate user permissions at every level:
// Good: Check permissions before action
async function deleteChannel(channelId: string, userId: string) {
const canDelete = await checkPermission(userId, channelId, 'DELETE_CHANNEL')
if (!canDelete) {
throw new ApiError('Insufficient permissions', 'FORBIDDEN', 403)
}
// Proceed with deletion
}Use strong password requirements:
// Good: Comprehensive password validation
export const passwordSchema = z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[a-z]/, 'Must contain lowercase letter')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[0-9]/, 'Must contain number')
.regex(/[^a-zA-Z0-9]/, 'Must contain special character')Never trust client-side validation alone:
// Bad: Only client-side check
if (user.role === 'admin') {
// Allow admin action
}
// Good: Server-side verification
const user = await getAuthenticatedUser(request)
if (!['owner', 'admin'].includes(user.role)) {
throw new ApiError('Forbidden', 'FORBIDDEN', 403)
}Never expose sensitive user data:
// Bad: Returning password hash
return { ...user, password: user.encrypted_password }
// Good: Exclude sensitive fields
const { encrypted_password, ...safeUser } = user
return safeUserAlways validate input with Zod schemas:
// Good: Comprehensive validation
import { validateRequestBody } from '@/lib/validation/validate'
import { sendMessageSchema } from '@/lib/validation/schemas'
export async function POST(request: NextRequest) {
const body = await validateRequestBody(request, sendMessageSchema)
// body is now typed and validated
}Sanitize user-generated content:
// Good: HTML sanitization
import { sanitizeHtml } from '@/lib/validation/validate'
const safeContent = sanitizeHtml(userInput)Validate file uploads thoroughly:
// Good: Multi-layer validation
const uploadInitSchema = z.object({
filename: z
.string()
.min(1)
.max(255)
.regex(/^[^<>:"/\\|?*]+$/, 'Invalid filename'),
contentType: z.string().regex(/^[a-z]+\/[a-z0-9\-\+\.]+$/i),
size: z
.number()
.int()
.positive()
.max(100 * 1024 * 1024, 'Max 100MB'),
})Never trust raw request data:
// Bad: Direct use of request data
const { email, password } = await request.json()
await db.query(`SELECT * FROM users WHERE email = '${email}'`)
// Good: Parameterized query with validation
const { email } = await validateRequestBody(request, signInSchema)
await pool.query('SELECT * FROM users WHERE email = $1', [email])Never allow unvalidated redirects:
// Bad: Open redirect vulnerability
const redirectTo = request.query.get('redirect')
return NextResponse.redirect(redirectTo)
// Good: Validate against whitelist
const redirectTo = request.query.get('redirect')
const allowedUrls = ['/dashboard', '/profile', '/settings']
if (!allowedUrls.includes(redirectTo)) {
return NextResponse.redirect('/dashboard')
}Implement rate limiting on all endpoints:
// Good: Aggressive rate limiting on auth endpoints
export const POST = compose(
withErrorHandler,
withRateLimit({ limit: 5, window: 900 }) // 5 per 15 min
)(handleSignIn)Use CSRF protection for state-changing operations:
// Good: CSRF protection on mutations
export const POST = compose(withErrorHandler, withCsrfProtection, withAuth)(handleUpdate)Return consistent error responses:
// Good: Structured error response
return errorResponse('Invalid credentials', 'INVALID_CREDENTIALS', 401, { attemptedEmail: email })Never expose stack traces in production:
// Bad: Leaking implementation details
catch (error) {
return NextResponse.json({ error: error.stack }, { status: 500 })
}
// Good: Generic error message
catch (error) {
console.error('Internal error:', error) // Log for debugging
return internalErrorResponse('An error occurred')
}Never allow unrestricted CORS:
// Bad: Wildcard CORS with credentials
withCors({
origin: '*',
credentials: true,
})
// Good: Specific origins
withCors({
origin: ['https://nchat.app', 'https://app.nchat.io'],
credentials: true,
})Always use parameterized queries:
// Good: Parameterized PostgreSQL query
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email])Use GraphQL with Hasura (auto-parameterized):
// Good: GraphQL mutation (safe by default)
const { data } = await apolloClient.mutate({
mutation: UPDATE_USER,
variables: { id, updates },
})Implement row-level security:
-- Good: RLS policy
CREATE POLICY user_access ON messages
FOR SELECT
USING (
user_id = current_user_id()
OR channel_id IN (SELECT id FROM user_channels WHERE user_id = current_user_id())
);Never build SQL queries with string concatenation:
// Bad: SQL injection vulnerability
const query = `SELECT * FROM users WHERE email = '${email}'`
await pool.query(query)
// Good: Use parameters
await pool.query('SELECT * FROM users WHERE email = $1', [email])Never store passwords in plain text:
// Bad: Plain text password
await db.users.insert({ email, password })
// Good: Hashed with bcrypt
const hashedPassword = await bcrypt.hash(password, 12)
await db.users.insert({ email, encrypted_password: hashedPassword })Validate file types with multiple checks:
// Good: Multi-layer file validation
function validateFile(file: File) {
// 1. Extension check
const ext = file.name.split('.').pop()?.toLowerCase()
if (!ALLOWED_EXTENSIONS.includes(ext)) {
throw new Error('Invalid file type')
}
// 2. MIME type check
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
throw new Error('Invalid MIME type')
}
// 3. Size check
if (file.size > MAX_FILE_SIZE) {
throw new Error('File too large')
}
// 4. Magic number check (optional)
const magicNumber = await readMagicNumber(file)
if (!isValidMagicNumber(magicNumber, ext)) {
throw new Error('File content does not match extension')
}
}Generate unique, unpredictable filenames:
// Good: UUID-based filename
import { randomUUID } from 'crypto'
const fileId = randomUUID()
const ext = filename.split('.').pop()
const key = `uploads/${year}/${month}/${day}/${fileId}.${ext}`Use presigned URLs with expiration:
// Good: Short-lived presigned URL
const presignedUrl = await generatePresignedUrl(
bucket,
key,
contentType,
900 // 15 minutes
)Never trust user-provided filenames:
// Bad: Using user filename directly
const filePath = `/uploads/${userFilename}`
// Good: Sanitize and generate safe filename
const safeFilename = sanitizeFilename(userFilename)
const filePath = `/uploads/${randomUUID()}-${safeFilename}`Never allow executable uploads:
// Bad: Allowing any file type
const ALLOWED_TYPES = ['*']
// Good: Explicit whitelist
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'text/plain']Use Content Security Policy:
// Good: Restrictive CSP in next.config.js
headers: [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'nonce-{NONCE}'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
].join('; '),
},
]Sanitize before rendering user content:
// Good: Using DOMPurify for rich content
import DOMPurify from 'dompurify'
function MessageContent({ html }: { html: string }) {
const sanitized = DOMPurify.sanitize(html)
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />
}Use secure cookie settings:
// Good: Secure cookie configuration
response.cookies.set('session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 86400,
})Never use dangerouslySetInnerHTML without sanitization:
// Bad: XSS vulnerability
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// Good: Sanitize first
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />Never store sensitive data in localStorage:
// Bad: Sensitive data in localStorage
localStorage.setItem('accessToken', token)
localStorage.setItem('password', password)
// Good: Use httpOnly cookies for tokens, never store passwords
// Token stored in httpOnly cookie (not accessible to JavaScript)Use environment variables for secrets:
// Good: Environment variable with validation
const JWT_SECRET = process.env.JWT_SECRET
if (!JWT_SECRET && process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET must be configured')
}Use a secrets manager in production:
// Good: AWS Secrets Manager integration
import { SecretsManager } from '@aws-sdk/client-secrets-manager'
async function getSecret(secretName: string) {
const client = new SecretsManager({ region: 'us-east-1' })
const response = await client.getSecretValue({ SecretId: secretName })
return JSON.parse(response.SecretString)
}Rotate secrets regularly:
// Good: Implement secret rotation
async function rotateJwtSecret() {
const newSecret = generateSecureSecret(32)
await secretsManager.updateSecret('JWT_SECRET', newSecret)
await notifyServices('JWT_SECRET rotated')
}Never commit secrets to version control:
# Bad: Secrets in .env file committed to Git
.env
# Good: .env in .gitignore
# Use .env.example with placeholder valuesNever use default or weak secrets:
// Bad: Weak default secret
const SECRET = process.env.SECRET || 'secret123'
// Good: Require strong secret, no defaults
const SECRET = process.env.SECRET
if (!SECRET) {
throw new Error('SECRET environment variable is required')
}
if (SECRET.length < 32) {
throw new Error('SECRET must be at least 32 characters')
}Never log sensitive information:
// Bad: Logging passwords
console.log('User login:', { email, password })
// Good: Log only non-sensitive data
console.log('User login:', { email })Use structured error handling:
// Good: Structured error with context
try {
await processPayment(amount)
} catch (error) {
if (error instanceof PaymentError) {
return errorResponse('Payment failed', 'PAYMENT_ERROR', 400, { reason: error.reason })
}
throw error // Re-throw unexpected errors
}Log errors with context:
// Good: Contextual error logging
catch (error) {
console.error('Payment processing failed:', {
userId,
amount,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error'
})
return internalErrorResponse()
}Never expose internal errors to users:
// Bad: Exposing database errors
catch (error) {
return NextResponse.json({
error: 'Database query failed',
details: error.message, // ❌ Leaks internal details
query: sql // ❌ Exposes SQL
})
}
// Good: Generic error message
catch (error) {
console.error('Database error:', error) // Log internally
return internalErrorResponse('An error occurred')
}Never suppress errors silently:
// Bad: Silent failure
try {
await importantOperation()
} catch {
// Ignored
}
// Good: Log and handle appropriately
try {
await importantOperation()
} catch (error) {
console.error('Important operation failed:', error)
await notifyAdmins(error)
throw error
}Write security-focused tests:
// Good: Test authentication
describe('POST /api/config', () => {
it('requires authentication', async () => {
const response = await fetch('/api/config', {
method: 'POST',
body: JSON.stringify({ branding: { appName: 'Test' } }),
})
expect(response.status).toBe(401)
})
it('requires admin role', async () => {
const response = await fetch('/api/config', {
method: 'POST',
headers: { Authorization: 'Bearer member-token' },
body: JSON.stringify({ branding: { appName: 'Test' } }),
})
expect(response.status).toBe(403)
})
})Test input validation:
// Good: Test validation boundaries
describe('Input Validation', () => {
it('rejects SQL injection attempts', () => {
const malicious = "admin' OR '1'='1"
expect(() => emailSchema.parse(malicious)).toThrow()
})
it('rejects XSS attempts', () => {
const malicious = "<script>alert('XSS')</script>"
const result = safeTextSchema.parse(malicious)
expect(result).not.toContain('<script>')
})
})Test rate limiting:
// Good: Verify rate limiting works
describe('Rate Limiting', () => {
it('blocks after limit exceeded', async () => {
// Make 6 requests (limit is 5)
const requests = Array(6)
.fill(null)
.map(() =>
fetch('/api/auth/signin', {
method: 'POST',
body: JSON.stringify({ email: '[email protected]', password: 'wrong' }),
})
)
const responses = await Promise.all(requests)
const lastResponse = responses[responses.length - 1]
expect(lastResponse.status).toBe(429) // Too Many Requests
})
})Never skip security tests:
// Bad: Skipping security tests
it.skip('SQL injection protection', () => {
/* ... */
})
// Good: Fix the test or the code
it('SQL injection protection', () => {
// Test implementation
})Authentication & Authorization:
- All new endpoints have appropriate authentication
- Role-based access control properly implemented
- No authentication bypasses in conditional logic
Input Validation:
- All user input validated with Zod schemas
- No direct use of
request.json()without validation - File uploads properly validated
SQL & Database:
- All queries parameterized (no string concatenation)
- No raw SQL with user input
- GraphQL queries use variables, not inline values
Secrets & Configuration:
- No hardcoded secrets or API keys
- Environment variables properly used
- No secrets in logs or error messages
Error Handling:
- Errors don't expose sensitive information
- Generic error messages for users
- Detailed errors only in server logs
Dependencies:
- New dependencies security-scanned
- No known vulnerabilities in new packages
- Dependency versions locked
Frontend:
- No
dangerouslySetInnerHTMLwithout sanitization - User content properly escaped
- No sensitive data in client-side storage
Testing:
- Security tests included for new features
- Edge cases tested
- Negative tests (invalid input) included
- String concatenation in SQL queries
-
ignoreBuildErrorsor disabled linting - Wildcard CORS configuration
- Default passwords or secrets
-
eval()orFunction()with user input - Disabled security middleware
- Direct file system access with user input
- Unvalidated redirects
- Missing rate limiting on new endpoints
- Authentication checks in frontend only
- npm audit - Dependency vulnerability scanning
- Snyk - Continuous security monitoring
- OWASP ZAP - Penetration testing
- SonarQube - Static code analysis
- OWASP WebGoat - Security training
- PortSwigger Web Security Academy - Free courses
- HackerOne CTF - Capture the Flag challenges
# 1. Run linting and type checking
npm run lint
npm run type-check
# 2. Check for security vulnerabilities
npm audit --audit-level=high
# 3. Run tests including security tests
npm test
# 4. Verify no secrets committed
git diff --cached | grep -i "secret\|password\|api.key"# 1. Rotate secrets
# 2. Review environment variables
# 3. Run full security audit
npm audit
# 4. Check security headers
# 5. Verify rate limiting
# 6. Test authentication flows
# 7. Review access logs# 1. Rotate all compromised credentials immediately
# 2. Review access logs for suspicious activity
# 3. Notify affected users
# 4. Document the incident
# 5. Implement fix and deploy
# 6. Post-mortem and prevention measuresRemember: Security is not a feature, it's a requirement.
Every line of code should be written with security in mind. When in doubt, ask for a security review.
Last Updated: January 31, 2026 Next Review: April 30, 2026