Secure Coding: Write Code That Resists Attacks

A developer's guide to code that holds up under attack: input handling, injection, authentication, authorisation, crypto, secrets, files, memory safety, dependencies, review and testing โ€“ with Python and JavaScript examples

Read the free first lesson below โ€” 12 lessons ยท CHF 9.90 one-time

12 lessons ยท knowledge checks ยท final exam ยท verifiable certificate

Unlock all 12 lessons ยท CHF 9.90 one-time Open in the course reader

Free lesson 1: The Secure Coding Map: Find, Fix and Test Your First Vulnerable Function

22 min read ยท lesson 1 of 12 ยท free, no sign-up

Code editor window with lines of code and a glowing shield bearing code brackets
Free lesson ยท Secure coding for developers

Most security flaws are not exotic. They are ordinary lines of code that trust a value they should not trust, and they look perfectly reasonable in a pull request. This lesson gives you the one mental model that explains almost every coding vulnerability โ€“ untrusted sources flowing into dangerous sinks โ€“ and then walks you through the full routine on real Python and JavaScript code: find the vulnerable function, understand why it breaks, fix it properly and prove the fix with a test.

  • 12lessons in the course
  • 2languages in every example: Python and JavaScript
  • 4steps per bug: find, understand, fix, test
  • ~20 minreading time for this lesson
Untrusted data flowing from a source to a sink Four boxes from left to right: Source, Validate, Decide and Sink. A dashed vertical line between Source and Validate marks the trust boundary. Below the Sink a box says to use a safe API at the sink: parameterise, encode, canonicalise and check. Below the other boxes a wide box says to fail safely: a generic error for the user, a detailed event in the log and never secrets in either. trust boundary SourceValidateDecideSink query, body,headers, cookies,files, queues type and lengthformat, allow-listreject early authenticateauthorise objectbusiness rules SQL, shell, HTMLfile path, URLdeserialiser Safe API at the sink parameterise ยท encode ยท check Fail safely on every path generic error to the user ยท detailed event to the log ยท no secrets Every lesson in this course zooms into one part of this picture.
Figure 1. The source-to-sink model. Data crosses a trust boundary, gets checked for shape, passes the business decisions (who may do what) and finally reaches a sink โ€“ a place where it can change behaviour. The decisive defence sits at the sink: an API that keeps data from being read as code.

Why secure coding matters: the bug is in the line you already wrote

From a developer's chair, most security stories are small. Someone built a query by gluing strings together. Someone checked that a user was logged in but not that the invoice belonged to them. Someone left a debug flag on, and an error page printed a connection string. None of these needed a genius to write โ€“ or to prevent.

It helps to use three words precisely, because the industry often blurs them:

  • A weakness is a type of mistake, such as "SQL built from untrusted strings". The CWE list catalogues these types; SQL injection is CWE-89, path traversal is CWE-22, cross-site scripting is CWE-79.
  • A vulnerability is a weakness that is actually reachable in a specific product: an attacker-controlled value really does arrive at that query.
  • An exploit is a concrete technique that abuses the vulnerability. As a developer, you rarely need the exploit. You need to recognise the weakness and remove the path.

That is the attitude of this course: no attack recipes, but the ability to see the shape of a vulnerable line, name the weakness and replace it with a pattern that is safe by construction. That skill transfers between languages far better than any list of payloads.

Why fixing it in code beats fixing it later

A flaw found while you write the function costs minutes: you know the context and the fix ships with the feature. Found in production, the same flaw needs an investigation, a hotfix, a release and possibly a notification to users. Firewalls, scanners and penetration tests see your code from the outside; the person with the clearest view of how data moves through a function is the person who wrote it.

Lists like the OWASP Top 10 (2021 edition and the 2025 update) group web risks into broad categories. They are excellent for awareness, but you cannot "implement" a category. This course turns them into concrete coding habits: which API to use at which sink, which check belongs on which line, and which test proves that the check works.

The mental model: sources, sinks and trust boundaries

Almost every coding vulnerability fits one sentence: an untrusted value reaches a sensitive operation without the right protection for that operation. Break the sentence into parts and you get a model you can apply to any codebase.

  • Source โ€“ where data enters that you do not control: URL parameters, form fields, JSON bodies, headers, cookies, uploaded files, message queues, webhooks, and data read back from your own database that originally came from users.
  • Trust boundary โ€“ the line where data changes from "someone else's" to "ours". An HTTP handler is a boundary; so is a function that reads a file uploaded by a customer.
  • Sink โ€“ an operation where data can change behaviour instead of just being stored or displayed as text: a SQL engine, a shell, an HTML page, the file system, an outgoing HTTP client, a deserialiser, a template engine, eval.
  • Decision points โ€“ places where your code decides who may do what. Missing decisions are as dangerous as broken sinks: an authorisation bug has no "malicious characters" at all.
  • 1 Sourcewhere does the value come from?
  • 2 Boundarywhere does it cross into our code?
  • 3 Shapeis type, length and format checked?
  • 4 Decisionis the caller allowed to do this?
  • 5 Sinkwhich API protects the operation?

The key insight is that the strongest defence is chosen by the sink, not by the source. The same string can be harmless in a log line, dangerous in a SQL query and dangerous in a completely different way inside HTML. That is why "sanitise all input" is a weak rule: you cannot know at the boundary which characters will be dangerous later. Validation at the boundary checks that data has the expected shape; the sink-specific API makes sure data is never interpreted.

SinkWeakness (CWE)Safe pattern (Python / JavaScript)Lesson
SQL querySQL injection (CWE-89)Placeholders: cur.execute(sql, params) / client.query(sql, [values])3
HTML pageCross-site scripting (CWE-79)Auto-escaping templates; textContent instead of innerHTML2
Operating-system commandOS command injection (CWE-78)Argument lists without a shell: subprocess.run([...]) / execFile(cmd, args)3
File pathPath traversal (CWE-22)Map IDs to files, or resolve the path and check it stays inside the base folder1, 8
Outgoing HTTP requestServer-side request forgery (CWE-918)Allow-list of destinations, block internal addresses, no blind redirects8
DeserialiserDeserialisation of untrusted data (CWE-502)Data-only formats such as JSON plus schema validation; never pickle on untrusted input8
Object lookup by IDAuthorisation bypass via user-controlled key (CWE-639)Scope every query to the current user or tenant, deny by default5
Password storeWeak password hash (CWE-916)Argon2id, scrypt or bcrypt through a maintained library4

When you review code, use this table backwards: find the sinks, then ask where each value comes from and what protects it on the way.

Worked example: find, understand, fix and test a vulnerable lookup

Here is a function you could find in any small Python service. It looks up a customer by e-mail address in SQLite. It works in every manual test. Read it once and see whether you spot the problem before the walkthrough does.

# customers.py  (vulnerable version - do not copy)
import sqlite3

def find_customer(conn: sqlite3.Connection, email: str):
    sql = f"SELECT id, name, email FROM customers WHERE email = '{email}'"
    return conn.execute(sql).fetchall()
  1. Find the sink. conn.execute(sql) hands a string to the SQL engine, which parses it as code. That is the sink. Anything inside sql becomes part of the program the database runs.
  2. Trace the source. email comes from the caller. Search for callers and you will typically find an HTTP handler that passes a query parameter straight through. So the value is attacker-controlled, and it crosses the trust boundary unchanged.
  3. Name the weakness. The f-string mixes code (the query text) and data (the e-mail) in one string. A quote character inside the e-mail ends the string literal early, and whatever follows is parsed as SQL. That is CWE-89, SQL injection.
  4. Understand the impact, not the exploit. If the value can change the query's logic, it can widen the WHERE clause and return every customer instead of one. You do not need more detail than that to know this is serious: the function's intent ("return one customer") can be overridden by its input.
  5. Fix it at the sink. Replace string building with a placeholder and pass the value separately. The query text becomes a constant, so no input can change its structure.
  6. Add shape validation as a second layer. An e-mail parameter should be a short string that looks like an address. Rejecting a 5000-character value early is cheap and keeps garbage out of logs and databases. It is a supporting control โ€“ the placeholder is the real fix.
  7. Prove it with a test. Write one test for the normal case and one for a perfectly legitimate address that contains an apostrophe, such as o'brien@example.com. The secure version must treat the apostrophe as data and find the right customer. You do not need an attack string to prove the bug: an honest customer already breaks the vulnerable query.

The fixed version:

# customers.py  (fixed version)
import re
import sqlite3

EMAIL_SHAPE = re.compile(r"[^@\s]{1,64}@[^@\s]{1,255}")

def find_customer(conn: sqlite3.Connection, email: str):
    if not isinstance(email, str) or len(email) > 320 or not EMAIL_SHAPE.fullmatch(email):
        raise ValueError("invalid e-mail")          # shape check: supporting layer
    sql = "SELECT id, name, email FROM customers WHERE email = ?"   # constant query text
    return conn.execute(sql, (email,)).fetchall()                   # value bound separately

Notice what the fix does not do: strip quotes, double them or block words like OR. Hand-escaping depends on the dialect, the encoding and on remembering it at every call site. A placeholder removes the whole class of problem โ€“ the difference between a patch and a design.

The tests that prove the fix

# test_customers.py  - run with: python -m pytest -q
import sqlite3
import pytest
from customers import find_customer

@pytest.fixture
def conn():
    c = sqlite3.connect(":memory:")
    c.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
    c.executemany("INSERT INTO customers (name, email) VALUES (?, ?)",
                  [("Ada", "ada@example.com"), ("Siobhan", "o'brien@example.com")])
    return c

def test_finds_exact_customer(conn):
    rows = find_customer(conn, "ada@example.com")
    assert [r[1] for r in rows] == ["Ada"]

def test_apostrophe_is_data_not_sql(conn):
    # A legal address: the apostrophe must be compared, never parsed.
    rows = find_customer(conn, "o'brien@example.com")
    assert [r[1] for r in rows] == ["Siobhan"]

def test_unknown_address_returns_nothing(conn):
    assert find_customer(conn, "nobody@example.com") == []

def test_rejects_wrong_shape(conn):
    with pytest.raises(ValueError):
        find_customer(conn, "a" * 5000)

Run the tests against the vulnerable version first. The apostrophe test fails there with sqlite3.OperationalError, a syntax error: the apostrophe closed the string literal early and the rest of the address was parsed as SQL. That error message is the injection, seen from the friendly side. Then run the tests against the fixed version. What you should see (example output; timings and formatting vary by pytest version):

$ python -m pytest -q
....                                                                     [100%]
4 passed in 0.02s

This "red, then green" order matters. A security test that has never failed proves nothing โ€“ it may not even reach the code you think it does. Keep it in the suite: if someone later "simplifies" the query back into an f-string, the build turns red.

Pro tip

Name security tests after the property they protect, not after the attack: test_apostrophe_is_data_not_sql tells the next developer exactly what must stay true. A name like test_sqli_payload invites someone to "update the payload" instead of understanding the rule.

Under the hood: why a placeholder makes injection impossible

The principle behind the fix โ€“ keep code and data in separate channels โ€“ returns in almost every lesson, so it is worth seeing it work. When you call conn.execute(sql) with a single string, the database receives one blob of text. Its tokenizer splits the text into keywords, identifiers, operators and string literals. A string literal starts at a quote and ends at the next unescaped quote. The tokenizer has no idea which characters came from your source code and which came from a customer. If the customer's value contains a quote, the literal ends where the customer decided, and the following characters become tokens of the query. That is the whole mechanism of SQL injection.

With a placeholder the sequence changes. In SQLite, for example, Python's sqlite3 module prepares the statement text ... WHERE email = ?, which compiles it into an internal program with an empty parameter slot. Then it binds your value to that slot as a typed value (text, in this case), and only then executes the program. The value never passes through the tokenizer, so a quote inside it is just a byte in a string that gets compared with the column.

String building versus a bound parameter Top lane: query text and the value are glued into one string, the SQL parser reads the apostrophe as the end of a literal, and the result is a syntax error or changed query logic. Bottom lane: the constant query text with a question-mark placeholder is parsed and compiled once, the value is bound to the slot separately, and at execution the value is compared purely as data. 1. String building (vulnerable) query + value = one string email = 'o'brien@...' SQL tokenizer quote ends the literal syntax error or changed query logic 2. Placeholder (fixed) query: email = ? value: o'brien@example.com parse + compile once bind value to slot ? execute value compared as data
Figure 2. The same customer value through two pipelines. In the top lane the tokenizer sees one string and cannot tell code from data. In the bottom lane the structure of the query is fixed before the value arrives, so the value can only ever be a value.

Three details separate people who have really understood this from people who memorised "use prepared statements":

  • Identifiers cannot be bound. Placeholders stand for values, not for table names, column names or sort directions. If a user may choose the sort column, map their choice to a fixed allow-list on the server ({"name": "name", "newest": "created_at"}) and insert only the mapped constant.
  • Some drivers emulate placeholders by quoting the value on the client side. That is still safe, because the driver applies the dialect's rules consistently โ€“ which is exactly why you should never assemble part of the query yourself.
  • A bound value can still carry meaning inside an operator. In WHERE name LIKE ?, a bound % or _ still acts as a wildcard. That is not injection โ€“ the query structure is intact โ€“ but it can turn a "starts with" search into a full-table scan. Escape wildcard characters and declare an ESCAPE character when the user should search literally.

A second, deeper example: path traversal in a Node.js download handler

Here the sink is the file system and the dangerous characters are ordinary path syntax. An Express route lets a logged-in customer download an invoice PDF by name:

// download.js  (vulnerable version - do not copy)
const express = require('express');
const path = require('node:path');
const fs = require('node:fs');

const app = express();
const BASE = path.join(__dirname, 'invoices');

app.get('/download', (req, res) => {
  const file = path.join(BASE, req.query.name);   // the caller decides the path
  fs.createReadStream(file).pipe(res);
});

Apply the model. The source is req.query.name. The sink is the file system read. The weakness is that path.join does not confine anything: it simply concatenates segments and normalises them, so a .. segment in the name moves the result up out of the invoices folder. That is CWE-22, path traversal. Any file the Node.js process can read โ€“ configuration, environment files, source code โ€“ becomes downloadable. There are two quieter bugs as well. Express can turn a repeated parameter such as ?name=a&name=b into an array, and path.join then throws a TypeError, which the default error handler turns into a 500 page โ€“ with a stack trace when the app runs in development mode. And because the read stream has no error listener, a request for a file that does not exist emits an unhandled 'error' event, which crashes the whole Node.js process. One missing check, three failure modes: data exposure, information leakage and denial of service.

The strongest fix avoids user-supplied paths entirely: store invoices under generated IDs, look up the file path in the database for the current user, and never let the client name a file. When a name genuinely has to come from the client, enforce two independent rules: a strict shape, and containment after resolution.

// safe-path.js  (fixed helper)
const path = require('node:path');

const BASE = path.resolve(__dirname, 'invoices');
const NAME_SHAPE = /^[A-Za-z0-9_-]{1,64}\.pdf$/;   // allow-list of characters

function resolveInside(base, userName) {
  if (typeof userName !== 'string' || !NAME_SHAPE.test(userName)) {
    throw new Error('invalid file name');
  }
  const target = path.resolve(base, userName);      // absolute, normalised
  if (!target.startsWith(base + path.sep)) {        // must stay below base
    throw new Error('path escapes base directory');
  }
  return target;
}

module.exports = { BASE, resolveInside };
// download.js  (fixed route)
const { BASE, resolveInside } = require('./safe-path');

app.get('/download', (req, res) => {
  let file;
  try {
    file = resolveInside(BASE, req.query.name);
  } catch {
    return res.status(400).send('Invalid file name');
  }
  // Lesson 5 adds the missing piece: does this invoice belong to the caller?
  res.sendFile(file, (err) => {
    if (err && !res.headersSent) res.status(404).send('Not found');
  });
});

Either rule alone stops the obvious case today. Together they survive tomorrow's change, when someone relaxes the name pattern and forgets why it was strict. That is defence in depth inside one function.

// safe-path.test.js  - run with: node --test
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { BASE, resolveInside } = require('./safe-path');

test('accepts a plain invoice name', () => {
  assert.equal(resolveInside(BASE, 'inv_2026-001.pdf'), path.join(BASE, 'inv_2026-001.pdf'));
});

test('rejects names that climb out of the base folder', () => {
  assert.throws(() => resolveInside(BASE, '../secrets.pdf'));
  assert.throws(() => resolveInside(BASE, '/etc/hosts'));
});

test('rejects non-string input such as ?name=a&name=b', () => {
  assert.throws(() => resolveInside(BASE, ['a.pdf', 'b.pdf']));
});

Example output with a recent Node.js version (the reporter format differs between versions):

$ node --test
โœ” accepts a plain invoice name (0.7ms)
โœ” rejects names that climb out of the base folder (0.3ms)
โœ” rejects non-string input such as ?name=a&name=b (0.2ms)
โ„น tests 3
โ„น suites 0
โ„น pass 3
โ„น fail 0
โ€ฆ  (more summary lines follow)

What went wrong if you see this

  • The containment check passes for a sibling folder. You compared with startsWith(base) instead of startsWith(base + path.sep). A folder called invoices-archive starts with the same characters as invoices.
  • The test passes but production still serves outside files. Check for symbolic links inside the base folder. path.resolve works on strings and does not follow links; if links are possible, resolve the real path with fs.realpathSync and check containment again.
  • Encoded dots get through. Express decodes the query string once. If your code calls decodeURIComponent again after validation, a double-encoded value is decoded after the check. Validate the final value that reaches the sink, never an earlier form of it.

Common secure coding mistakes โ€“ and what to do instead

Both examples share a pattern: the tempting fix works on the obvious case and fails on the next one.

Common mistake
  • "Sanitise all input" at the entry point and trust it afterwards
  • Escaping quotes or stripping ../ by hand
  • Blocklists of "bad" words or characters
  • Validation only in the browser or the mobile app
  • Trusting values because they came from your own database
  • Returning the exception text to the caller
Better
  • Check shape at the boundary, protect with the right API at each sink
  • Placeholders, argument lists, auto-escaping, ID-to-file maps
  • Allow-lists of what is valid; reject everything else
  • Repeat every check on the server โ€“ the client is not yours
  • Treat stored user data as untrusted when it reaches a new sink
  • A generic message for the user, the details in the log
Isn't strict input validation enough on its own?

No. Validation answers "does this look like an e-mail address?", and a legitimate address can still contain an apostrophe. If validation is your only defence, you must either reject real customers or let dangerous characters through. Validation reduces noise and catches garbage; the sink-specific API is what makes the operation safe.

Does an ORM or a modern framework make me safe automatically?

It makes the default path safe โ€“ ORM query builders bind values, and template engines such as Jinja2 in Flask or React's JSX escape output. Vulnerabilities appear at the escape hatches: raw SQL helpers fed with formatted strings, |safe filters, dangerouslySetInnerHTML, or a dynamic column name. Learn where your framework's escape hatches are and review them first.

Can a web application firewall replace the code fix?

A WAF can block known attack shapes and buy time while you patch. It does not understand your business logic and cannot see authorisation bugs at all. Treat it as a seatbelt, not as the brakes.

Troubleshooting: when the fix does not behave

Real fixes rarely work on the first try. These are the problems developers typically hit when they apply the patterns from this lesson, and how to resolve them.

SymptomLikely causeFix
sqlite3.ProgrammingError: Incorrect number of bindings suppliedYou passed (email), which is just a string in brackets, so each character counts as one parameterWrite a one-element tuple with a trailing comma: (email,)
Query works with SQLite, fails with PostgreSQLPlaceholder style differs between driversUse the driver's style: ? for sqlite3, %s for psycopg, $1 for node-postgres
Sorting by a placeholder has no effect or errorsIdentifiers cannot be bound; the column name became a constant stringMap the user's choice to an allow-list of column names
Real customers are rejected after adding validationPattern too strict: plus addressing, apostrophes, long domainsValidate shape loosely; prove ownership with a confirmation message

How experienced reviewers work through a change

Professionals do not read a pull request top to bottom. They follow a fixed order that spends time where bugs live and ignore what tools already cover, such as formatting. A typical budget for a medium-sized change:

  1. Scope โ€“ 5 minutesWhat does the change do, and which trust boundaries does it touch? A new route, a new upload, a new outgoing call or a new dependency raises the priority.
  2. Sinks first โ€“ 15 minutesSearch the diff for execute(, innerHTML, subprocess, child_process, open(, fs., fetch(, pickle, eval and template escape hatches.
  3. Trace back โ€“ 15 minutesFor each sink, follow the value to its source. Can a caller influence it? What protects it on the way?
  4. Decisions โ€“ 10 minutesEvery new endpoint: who may call it, and is ownership of each object checked on the server?
  5. Failure paths โ€“ 10 minutesErrors, timeouts, retries and logs. What does the user see, and what lands in the log?
  6. Evidence โ€“ 5 minutesIs there a test that would fail if the security check were deleted? If not, ask for one.

The thinking errors of experienced developers are assumptions, not syntax: "this endpoint is internal"; "the value comes from our own table"; "the framework escapes everything"; "the frontend validates it". Each fails silently the day the system around it changes. A good reviewer writes the assumption down and checks whether the code enforces it.

Where a reviewer's first hour typically goes (illustrative โ€“ not measured data)
Tracing sinks to sources30 min
Authorisation decisions10 min
Error and logging paths10 min
Scope and new dependencies5 min
Tests as evidence5 min

The defender's view: make the safe pattern the only pattern

Fixing one function is good. Making sure the vulnerable pattern cannot come back is better. Defenders work on three levels at once.

1. Detect the pattern automatically

Static analysis tools can flag the exact shape you removed today. A small custom rule for Semgrep, for example, catches SQL built with string formatting before it is merged:

# rules/sql-format.yml  - run with: semgrep scan --config rules/sql-format.yml .
rules:
  - id: python-sql-built-from-strings
    languages: [python]
    severity: ERROR
    message: SQL text is built from strings. Use a constant query with placeholders.
    pattern-either:
      - pattern: $DB.execute(f"...", ...)
      - pattern: $DB.execute("..." % $X, ...)
      - pattern: $DB.execute("..." + $X, ...)
      - pattern: $DB.execute("...".format(...), ...)

Test the rule like code: it must flag the vulnerable version from this lesson and stay silent on the fixed one. A rule that has never fired is as untrustworthy as a test that has never failed.

2. Limit the damage when something slips through

Run the application's database account with only the rights it needs โ€“ no schema changes, no access to other applications' tables. Run the Node.js process as an unprivileged user that cannot read configuration it does not need. Neither prevents the bug, but both shrink what a bug can reach.

3. Notice abuse

Validation failures are a signal. A burst of rejected, path-shaped values against one endpoint is worth an alert. Log the event, the route and a request ID โ€“ not the raw value if it might contain personal data. Lesson 7 builds this into a proper logging design.

Choosing the defence by sink A decision tree. The root says an untrusted value reaches a sink. Six branches: SQL uses placeholders and allow-lists for identifiers; HTML uses auto-escaping, textContent and a sanitiser for rich text; commands use no shell and an argument list; file paths use an ID-to-file map or resolve and contain; outgoing URLs use an allow-list, block internal addresses and avoid redirects; deserialisers use JSON with a schema and no native object formats. A note below says shape validation supports every branch but never replaces the sink defence. Untrusted value reaches a sink SQLHTMLCommand File pathOutgoing URLDeserialiser placeholdersidentifiers:allow-list auto-escapingtextContentsanitiser forrich text no shellargument listfixed binary ID-to-file mapor resolveand contain allow-listblock internalno redirects JSON + schemano pickle ornative objects Shape validation supports every branch โ€“ it never replaces the sink defence.
Figure 3. A decision tree you can apply in review. Identify the sink first, then check that the matching defence is in place. Blue branches are covered in Lessons 2 and 3, green branches in Lesson 8.

Check yourself: six questions on the secure coding basics

Check yourself

1. A function builds "SELECT * FROM orders WHERE ref = '" + ref + "'". What is the root-cause fix?

  1. Remove quotes from ref before building the string
  2. Use a constant query with a placeholder and pass ref as a parameter
  3. Put a web application firewall in front of the service
Show answer

B โ€“ the placeholder keeps the query structure fixed, so the value can never become code. Stripping quotes is fragile and a WAF is only an extra layer.

2. Why is "sanitise all input at the entry point" a weak rule?

  1. Because the dangerous characters depend on the sink the value reaches later
  2. Because input validation is slow
  3. Because frameworks forbid it
Show answer

A โ€“ a quote is harmless in a log line and dangerous in SQL; angle brackets matter in HTML but not in a file name. Check shape at the boundary, protect at each sink.

3. A user may choose the sort column of a report. Placeholders do not work for column names. What should you do?

  1. Escape the column name
  2. Allow only letters in the value
  3. Map the choice to a fixed allow-list of column names on the server
Show answer

C โ€“ identifiers cannot be bound, so the server must choose the identifier from its own list. The user only selects a key.

4. A containment check uses target.startsWith(base). What is the flaw?

  1. It is too slow for large folders
  2. A sibling folder whose name starts with the same characters also passes
  3. It does not work with absolute paths
Show answer

B โ€“ /srv/invoices-archive starts with /srv/invoices. Compare with base + path.sep.

5. You wrote a security test and it passes. What should you check before trusting it?

  1. That it fails against the vulnerable version of the code
  2. That it runs in under one second
  3. That it uses a real attack payload
Show answer

A โ€“ a test that has never failed may not reach the code you think it reaches. Red on the vulnerable version, green on the fix, is your evidence.

6. Which statement about an ORM is correct?

  1. An ORM makes SQL injection impossible in every case
  2. ORMs never bind parameters
  3. The default query builder is safe; raw-SQL helpers fed with formatted strings are not
Show answer

C โ€“ the risk lives at the escape hatches. Review raw queries, dynamic identifiers and template "safe" filters first.

  • Name the parts โ€“ identify source, trust boundary, decision point and sink in a function you did not write
  • Choose by sink โ€“ pick the defence that matches the operation, not the input
  • Fix SQL properly โ€“ replace string-built queries with placeholders and allow-list identifiers
  • Contain file paths โ€“ resolve a path and verify it stays inside its base folder, or avoid client-supplied names entirely
  • Layer your checks โ€“ combine shape validation with the sink defence so one mistake does not reopen the hole
  • Prove it โ€“ write a test that is red on the vulnerable code and green on the fix
  • Review in order โ€“ sinks first, then sources, decisions, failure paths and evidence
  • Keep it fixed โ€“ add a static-analysis rule and least-privilege accounts so the bug cannot return quietly

Glossary

SourceAny place where data you do not control enters your code: requests, files, queues, webhooks, stored user data.
SinkAn operation where data can change behaviour: SQL, shell, HTML, file system, outgoing requests, deserialisers.
Trust boundaryThe line where data passes from a less trusted zone into your code and must be checked.
Weakness (CWE)A category of coding mistake, catalogued in the Common Weakness Enumeration, such as CWE-89 SQL injection.
VulnerabilityA weakness that is actually reachable and exploitable in a specific product.
PlaceholderA marker such as ? or $1 in query text that is filled by a separately bound value.
Allow-listA list of permitted values or patterns; everything not on it is rejected.
Path traversalUsing path syntax such as .. to reach files outside the intended folder (CWE-22).
CanonicalisationConverting a value to its single standard form, such as an absolute normalised path, before checking it.
Defence in depthIndependent layers of protection, so that one failure does not lead directly to compromise.
Escape hatchA framework feature that deliberately bypasses its safe default, such as raw SQL or unescaped HTML output.
Find the sink, trace the source, fix it with the API that keeps data from becoming code, and prove it with a test that fails without the fix.This four-step routine is the backbone of the course. Every following lesson applies it to one more class of sinks and decisions.

What's next in this course

  1. Lesson 2 โ€“ Input validation & output encoding: designing validation that rejects garbage without rejecting customers, and encoding output correctly for HTML, attributes, URLs and JavaScript.
  2. Lesson 3 โ€“ Injection classes & parameterised queries: SQL, NoSQL, OS command, template and LDAP injection, each with its safe API in Python and JavaScript.
  3. Lesson 4 โ€“ Authentication, sessions & password storage: Argon2id and bcrypt in practice, session cookies, token lifetimes and login rate limiting.
  4. Lesson 5 โ€“ Authorisation done right: IDOR and BOLA, object-level checks, tenant scoping and deny-by-default middleware.
  5. Lesson 6 โ€“ Using cryptography correctly: choosing vetted libraries, authenticated encryption, secure randomness and key handling โ€“ without inventing your own.
  6. Lesson 7 โ€“ Secrets, error handling & security logging: keeping credentials out of code, failing safely and logging what defenders need.
  7. Lesson 8 โ€“ Files, deserialisation & SSRF defence: safe uploads, archive extraction, data-only formats and outgoing-request allow-lists.
  8. Lesson 9 โ€“ Memory safety: what buffer overflows and use-after-free mean, why memory-safe languages matter, and where Python and Node.js still touch native memory.
  9. Lesson 10 โ€“ Dependencies & the supply chain: lockfiles, hashes, audits, install scripts and how to evaluate a package before you add it.
  10. Lesson 11 โ€“ Threat modelling, code review & security tests: function-level threat models, a review checklist, unit, property-based and fuzz tests.
  11. Lesson 12 โ€“ Capstone & next steps: review and fix a complete small service end to end, plus 20 practice questions.

Lessons 2โ€“12 and the final exam unlock with a one-time purchase โ€“ no subscription.

Frequently asked questions

What is secure coding?

Secure coding means writing software so that untrusted input cannot change what the code does beyond its intent. In practice it means validating the shape of input at trust boundaries, using safe APIs at sinks such as SQL, HTML, shell and the file system, checking authorisation on the server and proving each defence with a test.

How do you prevent SQL injection in Python?

Keep the query text constant and pass values as parameters, for example conn.execute("SELECT id FROM customers WHERE email = ?", (email,)) with sqlite3. The driver binds the value separately, so it can never become SQL. Column names cannot be bound; map user choices to an allow-list instead.

Is input validation enough to stop injection attacks?

No. Validation checks that data has the expected shape, but legitimate data such as an address with an apostrophe can still contain characters that are dangerous at a particular sink. Validation is a supporting layer; the decisive defence is the sink-specific API, such as placeholders for SQL or auto-escaping for HTML.

How do you prevent path traversal in Node.js?

Avoid client-supplied file names where possible and map IDs to files on the server. If a name must come from the client, allow only a strict pattern, resolve it with path.resolve against the base folder and check that the result starts with the base folder plus path.sep. Resolve symbolic links if they can occur.

What are sources and sinks in secure code review?

A source is where data you do not control enters the code, such as request parameters, headers, uploaded files or stored user data. A sink is an operation where that data can change behaviour, such as a SQL query, a shell command, HTML output or a file read. Reviewers find sinks first and trace values back to their sources.

How do you test that a security fix works?

Write a unit test for the property the fix protects, run it against the vulnerable version and confirm that it fails, then confirm that it passes on the fixed version. Keep it in the test suite so the build fails if someone reintroduces the vulnerable pattern.

Continue with lessons 2โ€“12

One-time purchase, no subscription. Pay as a guest โ€” no account needed.

Unlock all 12 lessons ยท CHF 9.90 one-time Open in the course reader

Course syllabus

Lessons unlock after a single one-time purchase โ€” no subscription. Pricing & FAQ โ†’ ยท All course overviews โ†’