Index Ai Academy
Phase 7 · Software engineering · Building software

Secure coding

Injection, escaping, secrets and passwords: the mistakes attackers look for.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Attackers don't need your source code. They send strange input and watch what breaks. The same small set of mistakes causes most real breaches: pasting input into commands, showing user text unescaped, storing passwords badly, leaking keys. AI apps add new doors, because models read untrusted text and can call tools. The habits in this lesson close the old doors, and they're the base for defending the new ones.

What you'll be able to do

  • Treat every input as hostile, and spot injection in SQL, file paths, shell commands and HTML
  • Escape output so user text can't become code in a web page
  • Store passwords with a slow, salted hash and check them in constant time
  • Keep secrets out of code, logs and git, and apply least privilege

What it covers

  1. Think like an attacker
  2. Escape output: cross-site scripting
  3. Passwords: slow, salted hashes
  4. PBKDF2, built from HMAC
  5. Secrets, randomness and least privilege

Think like an attacker

A building's front desk treats everyone coming in from the street differently from people already inside. A trust boundary is that front desk in your code: any place where data crosses from outside your control to inside it. A form field, a URL, an uploaded file, an API response, an email, a model's output. On the outside of that line, assume everything is hostile. That doesn't mean your users are bad. It means that if one attacker can send it, your code must handle it.

Most attacks are injection: data gets treated as code. You saw SQL injection last lesson. The same mistake has other forms.

Path traversal

Picture a clerk told "fetch the file from two cabinets back, then the boss's drawer", who does as asked. A download feature reads files/<name>. An attacker asks for ../../etc/passwd. Each .. climbs one folder up, out of the folder you meant to share. The fix: work out the real, final path, then check it's still inside the allowed folder.

from pathlib import Path

root = Path("/srv/uploads").resolve()

def is_safe(name):
    target = (root / name).resolve()      # resolve follows .. and gives the final path
    return target.is_relative_to(root)

for name in ["report.pdf", "2026/q3.csv", "../../etc/passwd", "/etc/passwd"]:
    print(f"{name!r:22} safe: {is_safe(name)}")
It runs right here in your browser, with nothing to install.

Notice /etc/passwd. Joining an absolute path onto another path throws the first one away entirely. That's a classic surprise, and checking the resolved result catches it.

Shell command injection

Never build a shell command by pasting in user text. A filename like photo.jpg; rm -rf ~ runs a second command. In Python, pass the program and its arguments as a list, which never goes through a shell:

subprocess.run(["convert", filename, "out.png"])            # safe: filename is one argument
subprocess.run(f"convert {filename} out.png", shell=True)   # unsafe: the shell reads it as code

In plain words: whatever comes from outside is data. Never let it be read as instructions.

Try it now

Most attacks are the same mistake: data being treated as code.

Quick check
Which input should a safe file-download function reject?
  1. ../config/secrets.env
  2. report final.pdf
  3. café-menu.pdf
  4. invoices/2026-10.pdf
Show the answer

../config/secrets.env

The .. climbs out of the shared folder. Spaces and accented letters are fine; what matters is where the resolved path ends up.

Escape output: cross-site scripting

Think of a community noticeboard. If a note could run down the street and pick visitors' pockets, you'd want it behind glass. If a web page shows a comment exactly as typed, a commenter can type HTML. A <script> tag then runs in every visitor's browser, where it can steal their session. That's cross-site scripting (XSS), injection into a web page.

The fix is escaping, the glass: turning the characters that mean something in HTML into harmless codes, so the browser shows them as text:

import html

comment = '<script>steal(document.cookie)</script> Great tent!'
print(html.escape(comment))
print(html.escape('He said "5 < 6" & left'))

Escape at the moment you put text into HTML, every time. Web frameworks and template engines do this automatically. The danger comes when you build HTML strings yourself, or switch the automatic escaping off. Model output counts as user input here. A model that summarized a hostile web page can repeat a script tag word for word.

Passwords: slow, salted hashes

Never store passwords as they were typed. When (not if) a database leaks, every user's password leaks with it, and people reuse passwords everywhere.

Store a hash instead: a one-way function that turns a password into a fixed-size scramble. You can turn a cow into a burger, and never a burger back into a cow. To check a login, hash what was typed and compare. Two more details make it safe:

  1. Salt. Add a random value, the salt, to each password before hashing, and store it with the hash. Then two users with the password "summer2026" get different hashes. An attacker can't use a precomputed table of common passwords.
  2. Slow on purpose. A normal hash such as SHA-256 is built to be fast: billions per second on a graphics card. That's terrible for passwords, because attackers can guess quickly. Picture a lock that takes a second to try each key. You won't notice, and a burglar with a million keys gives up. Password hashes repeat the work many thousands of times, so each guess is expensive.

PBKDF2 repeats an HMAC (a keyed hash) thousands of times. The standard algorithms are Argon2, bcrypt, scrypt and PBKDF2. In real code use a library, such as argon2-cffi, bcrypt or hashlib.pbkdf2_hmac, with current settings: about 600,000 rounds for PBKDF2-SHA256. The academy's browser Python doesn't include those, so the example builds PBKDF2 from hmac with fewer rounds, purely to show how it works.

Finally, compare hashes with hmac.compare_digest, not ==. An ordinary comparison stops at the first different character, so its timing leaks how much of a guess was right. compare_digest always takes the same time.

Worked example

PBKDF2, built from HMAC

This matches the published test value for PBKDF2-HMAC-SHA256 with password "password" and salt "salt".

import hmac, hashlib

def pbkdf2_sha256(password: bytes, salt: bytes, iterations: int) -> bytes:
    mac = hmac.new(password, digestmod=hashlib.sha256)
    def prf(data):
        m = mac.copy()
        m.update(data)
        return m.digest()
    u = prf(salt + b"\x00\x00\x00\x01")
    acc = int.from_bytes(u, "big")
    for _ in range(iterations - 1):
        u = prf(u)
        acc ^= int.from_bytes(u, "big")
    return acc.to_bytes(32, "big")

print(pbkdf2_sha256(b"password", b"salt", 1).hex())
print(pbkdf2_sha256(b"password", b"salt", 4096).hex())
In the lesson you can change this and run it.

Each round feeds the last result back through the HMAC and folds it in with XOR (^). The first line should start 120fb6cf and the second c5e478d5, the published values. That's how you check crypto code: against official test vectors, never by eye. Even so, use a vetted library in production. Getting crypto subtly wrong is easy and silent.

Quick check
Two users pick the same password. With salted hashing, what's stored?
  1. The password itself, encrypted
  2. Different hashes, because each has its own random salt
  3. Nothing, the password is only checked once
  4. The same hash, so the database can spot reused passwords
Show the answer

Different hashes, because each has its own random salt

The salt makes every stored hash unique, which defeats precomputed tables and hides reuse.

Secrets, randomness and least privilege

Secrets are API keys, database passwords and signing keys. Treat them like house keys, and don't leave them under the mat.

  • Keep them out of code and out of git. Read them from environment variables or a secret manager. Leaked keys on GitHub are found by bots within minutes.
  • Keep them out of logs and error messages. Log "key ending in ...a9F2" if you must log anything.
  • Rotate (replace) any key that may have leaked, at once. Deleting the commit isn't enough, because git history and copies remain.

Randomness for security comes from the secrets module: secrets.token_urlsafe(32) for reset links, secrets.token_bytes(16) for salts. The random module is predictable by design, so it's fine for games and shuffling, never for tokens.

Least privilege means giving each part only the access it needs. A cleaner gets keys to the offices, not to the safe. A reporting script gets a read-only database user. An AI agent that drafts emails gets no "send" tool. If the part is compromised or tricked, the damage stays small.

Dependencies are code you didn't write but run anyway. Pin versions, update them, and check for known flaws with tools like pip-audit. Watch for typosquatting, fake packages named one letter off a popular one, such as reqeusts.

The OWASP Top 10 is the industry's list of the most common web security risks. Read it once a year: injection, broken access control and misconfiguration are always near the top.

Quick check
secrets.token_hex(16) makes 16 random bytes, written as hex. What does this print?
import secrets

token = secrets.token_hex(16)
print(len(token))
  1. 8
  2. 64
  3. 32
  4. 16
Show the answer

32

Each byte becomes two hex characters, so 16 bytes make 32. Use secrets, not random, for anything an attacker must not guess.

Practice what you just read

  • 4 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 6 review cards for spaced repetition

Create a free account

Try a quiz question

Why are fast hashes like SHA-256 bad for storing passwords?

  1. Attackers can try billions of guesses per second against a leaked hash
  2. They produce hashes too short to be unique, so two passwords can match
  3. They can't handle long passwords, so anything past a limit gets cut off
  4. They're not available in Python without installing a third-party package

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson