Security in fullstack applications is complex and constantly evolving. Over the years, I've learned that robust security requires going beyond checklists - it demands understanding attack vectors, anticipating threats, and building multiple layers of protection. Let me share what I've learned from securing real production systems.
Authentication and authorization are crucial for application security. There are several approaches, but here's what has worked well in my experience:
JWTs can be tricky to implement correctly. Here's an approach that has worked for me:
// auth/jwt.service.ts
interface JwtPayload {
userId: string;
role: string;
iat?: number;
exp?: number;
}
class JwtService {
private readonly secret: string;
private readonly expiresIn: string;
constructor() {
this.secret = process.env.JWT_SECRET;
this.expiresIn = '1h';
}
async generateToken(payload: JwtPayload): Promise<string> {
return jwt.sign(
{
...payload,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
},
this.secret,
{ algorithm: 'HS256' }
);
}
async verifyToken(token: string): Promise<JwtPayload> {
try {
return jwt.verify(token, this.secret) as JwtPayload;
} catch (error) {
throw new AuthenticationError('Invalid token');
}
}
}
There are several ways to handle token storage, each with its own trade-offs:
// auth/tokenStorage.ts
class TokenStorage {
// One approach: Using httpOnly cookies for sensitive data
static setToken(token: string): void {
document.cookie = `auth_token=${token}; path=/; secure; samesite=strict`;
}
// Another approach: Using localStorage with additional safeguards
static setTokenLocal(token: string): void {
localStorage.setItem('auth_token', token);
// Add event listener for additional security
window.addEventListener('storage', this.handleStorageEvent);
}
private static handleStorageEvent(event: StorageEvent): void {
if (event.key === 'auth_token' && event.newValue !== event.oldValue) {
this.clearToken();
}
}
}
// middleware/auth.ts
export async function authMiddleware(req: NextRequest) {
const token = req.cookies.get('auth_token');
if (!token) {
return NextResponse.redirect(new URL('/login', req.url));
}
try {
const payload = await verifyToken(token.value);
req.user = payload;
return NextResponse.next();
} catch (error) {
return NextResponse.redirect(new URL('/login', req.url));
}
}
When it comes to API security, I've found that a multi-layered approach works best. Here are some strategies that have proven effective:
// middleware/rateLimit.ts
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
const limiter = rateLimit({
store: new RedisStore({
client: redisClient,
prefix: 'rate_limit:',
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests, please try again later',
});
// Apply to specific routes
app.use('/api/auth', limiter);
I've seen SQL injection vulnerabilities in many codebases. Here's how we can avoid them:
// db/queries.ts
class UserRepository {
// This approach is vulnerable to SQL injection
async findUserByName(name: string) {
return db.query(`SELECT * FROM users WHERE name = '${name}'`);
}
// A safer approach using parameterized queries
async findUserByName(name: string) {
return db.query('SELECT * FROM users WHERE name = ?', [name]);
}
// Using an ORM can provide additional protection
async findUserByName(name: string) {
return User.findOne({ where: { name } });
}
}
Validation should happen at multiple levels:
// utils/validation.ts
const validationRules = {
email: {
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email',
},
password: {
required: true,
minLength: 8,
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/,
message: 'Password must contain uppercase, lowercase, and numbers',
},
};
// React component
function RegistrationForm() {
const [errors, setErrors] = useState({});
const validateField = (name: string, value: string) => {
const rules = validationRules[name];
if (!rules) return true;
if (rules.required && !value) {
setErrors((prev) => ({ ...prev, [name]: 'This field is required' }));
return false;
}
if (rules.pattern && !rules.pattern.test(value)) {
setErrors((prev) => ({ ...prev, [name]: rules.message }));
return false;
}
return true;
};
}
// middleware/validation.ts
import { body, validationResult } from 'express-validator';
const validateRegistration = [
body('email')
.isEmail()
.normalizeEmail()
.withMessage('Please provide a valid email'),
body('password')
.isLength({ min: 8 })
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/)
.withMessage('Password must contain uppercase, lowercase, and numbers'),
body('name')
.trim()
.isLength({ min: 2 })
.withMessage('Name must be at least 2 characters long'),
];
// In your route handler
app.post('/register', validateRegistration, async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Process registration
});
// utils/encryption.ts
import crypto from 'crypto';
class EncryptionService {
private readonly algorithm = 'aes-256-gcm';
private readonly key: Buffer;
constructor() {
this.key = crypto.scrypt(process.env.ENCRYPTION_KEY, 'salt', 32);
}
encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`;
}
decrypt(encryptedText: string): string {
const [ivHex, encrypted, authTagHex] = encryptedText.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}
// server.ts
import https from 'https';
import fs from 'fs';
const options = {
key: fs.readFileSync('private.key'),
cert: fs.readFileSync('certificate.crt'),
// Enable modern TLS settings
minVersion: 'TLSv1.2',
ciphers: [
'ECDHE-ECDSA-AES128-GCM-SHA256',
'ECDHE-RSA-AES128-GCM-SHA256',
].join(':'),
honorCipherOrder: true,
};
const server = https.createServer(options, app);
// utils/logger.ts
import winston from 'winston';
import { ElasticsearchTransport } from 'winston-elasticsearch';
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new ElasticsearchTransport({
level: 'info',
index: 'app-logs',
client: elasticsearchClient,
}),
],
});
// Usage in middleware
app.use((req, res, next) => {
logger.info('Incoming request', {
method: req.method,
path: req.path,
ip: req.ip,
userAgent: req.get('user-agent'),
});
next();
});
// security/monitoring.ts
class SecurityMonitor {
private readonly alerts: AlertService;
private readonly metrics: MetricsService;
async monitorAuthAttempts(userId: string, success: boolean): Promise<void> {
const key = `auth:${userId}`;
const attempts = await redis.incr(key);
if (attempts > 5 && !success) {
await this.alerts.send({
type: 'AUTH_FAILURE',
userId,
attempts,
timestamp: new Date(),
});
// Block further attempts
await redis.expire(key, 3600); // 1 hour
}
}
async monitorApiUsage(route: string, ip: string): Promise<void> {
const key = `api:${route}:${ip}`;
const usage = await redis.incr(key);
if (usage > 1000) {
// Threshold
await this.metrics.record('API_ABUSE', {
route,
ip,
usage,
});
}
}
}
// middleware/security.ts
import helmet from 'helmet';
import xss from 'xss-clean';
app.use(helmet()); // Sets various HTTP headers for security
app.use(xss()); // Sanitizes user input
// In your React components
function UserProfile({ user }) {
// BAD: Vulnerable to XSS
return <div dangerouslySetInnerHTML={{ __html: user.bio }} />;
// GOOD: Sanitize or escape content
return <div>{escapeHtml(user.bio)}</div>;
}
// middleware/csrf.ts
import csrf from 'csurf';
const csrfProtection = csrf({ cookie: true });
// Apply to routes that modify state
app.post('/api/update-profile', csrfProtection, (req, res) => {
// The CSRF token is automatically validated
// Process the request
});
// In your frontend
function UpdateProfileForm() {
const [csrfToken, setCsrfToken] = useState('');
useEffect(() => {
// Fetch CSRF token when component mounts
fetch('/api/csrf-token')
.then((res) => res.json())
.then((data) => setCsrfToken(data.token));
}, []);
return (
<form onSubmit={handleSubmit}>
<input type='hidden' name='_csrf' value={csrfToken} />
{/* form fields */}
</form>
);
}
Security isn't a one-time implementation; it's an ongoing process that requires:
Remember: The most secure system is one that's designed with security in mind from the start, not bolted on as an afterthought.
Got questions about implementing any of these security measures? Feel free to reach out. Stay secure!