EthicsComputerCourses
Join training +91 90532 10052

Secure Coding Basics

Python · Lesson 63 of 68 Advanced 8 min read Updated

Most security problems in real applications are not clever attacks. They are ordinary mistakes: an API key pushed to GitHub, a password stored as plain text, a form that trusts whatever the user types, a file download that lets someone read ../config.env. Each mistake is easy to avoid once you know the pattern.

This lesson covers the habits every Python developer is expected to have: handling secrets, generating tokens and OTPs, storing passwords, validating input, avoiding dangerous functions, protecting file paths, and keeping dependencies safe. Everything here is defensive: it is about protecting your own programs and your users' data.

Secrets belong outside the code

A secret is anything that grants access: API keys, database passwords, payment gateway credentials, signing keys. If it is written in your source code, it ends up in Git history, in screenshots and on every laptop that clones the project. Removing it later does not help, because Git keeps old versions.

Instead, read secrets from environment variables, which are set outside the program by your terminal, your server or your hosting platform. Check them at start-up and stop with a clear message if one is missing, and never print a full secret, not even in logs:

PythonRun it yourself
import os
import secrets


class ConfigError(Exception):
    pass


def require_env(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise ConfigError(f"missing environment variable {name}")
    return value


def mask(secret: str) -> str:
    return secret[:4] + "*" * (len(secret) - 4)


os.environ["SMS_API_KEY"] = "demo-7f3a9c2e51"     # normally set outside the code
key = require_env("SMS_API_KEY")
print("Using SMS key", mask(key))

try:
    require_env("PAYMENT_SECRET")
except ConfigError as error:
    print("Startup stopped:", error)

otp = "".join(secrets.choice("0123456789") for _ in range(6))
reset_token = secrets.token_urlsafe(32)
print(len(otp), otp.isdigit(), len(reset_token))
Output
Using SMS key demo***********
Startup stopped: missing environment variable PAYMENT_SECRET
6 True 43

On your own computer, many projects keep development values in a .env file that a library such as python-dotenv loads into the environment. Add .env to .gitignore before you create it, and commit a .env.example with empty values so teammates know which variables exist.

The last lines show the second rule: use the secrets module for anything an attacker must not guess, such as OTPs, password-reset links and session tokens. The random module is designed for games and simulations; its numbers can be predicted, so it must never be used for security.

Storing passwords

Never store passwords as plain text, and never "encrypt" them so they can be decrypted. Store a password hash: the output of a deliberately slow one-way function. At login you hash the typed password the same way and compare. Each password gets its own random salt, so two users with the same password get different hashes. Python's hashlib.scrypt is a suitable slow function:

Python
import hashlib
import hmac
import secrets


def hash_password(password: str) -> str:
    salt = secrets.token_bytes(16)
    digest = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)
    return f"scrypt${salt.hex()}${digest.hex()}"


def verify_password(password: str, stored: str) -> bool:
    _, salt_hex, digest_hex = stored.split("$")
    digest = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt_hex), n=2**14, r=8, p=1)
    return hmac.compare_digest(digest.hex(), digest_hex)


stored = hash_password("Chai@Pune2026")
print("Right password:", verify_password("Chai@Pune2026", stored))
print("Wrong password:", verify_password("chai@pune2026", stored))
print("Same password, different hash:", hash_password("Chai@Pune2026") != stored)
Output
Right password: True
Wrong password: False
Same password, different hash: True

hmac.compare_digest() compares in constant time, so an attacker cannot learn anything from how long a comparison takes. Fast hashes such as hashlib.sha256 are the wrong tool here, because attackers can try billions of guesses per second against them. In real projects, prefer your framework's password system (Django has one built in) or a maintained library such as argon2-cffi, rather than your own code. This example needs a full Python installation; the browser editor's Python does not include scrypt.

Validate input with allow-lists

Treat every value from outside your program as untrusted: form fields, URL parameters, uploaded files, API responses, CSV rows. Validate it at the point where it enters: check the type, the format, the length and the allowed range, and reject everything else. Prefer an allow-list (exactly what is permitted) to a block-list (what is forbidden), because attackers are better at inventing bad input than you are at listing it.

PythonRun it yourself
import re
from decimal import Decimal, InvalidOperation

MOBILE = re.compile(r"[6-9]\d{9}")
PIN_CODE = re.compile(r"[1-9]\d{5}")
STATES = {"Haryana", "Rajasthan", "Maharashtra", "Kerala", "Uttar Pradesh"}


def validate_order(form: dict[str, str]) -> list[str]:
    errors = []
    mobile = form.get("mobile", "").replace(" ", "").removeprefix("+91")
    if not MOBILE.fullmatch(mobile):
        errors.append("mobile: enter a 10-digit Indian mobile number")
    if not PIN_CODE.fullmatch(form.get("pin", "").strip()):
        errors.append("pin: enter a 6-digit PIN code")
    if form.get("state") not in STATES:
        errors.append("state: choose a state from the list")
    try:
        quantity = int(form.get("quantity", ""))
        if not 1 <= quantity <= 10:
            errors.append("quantity: must be between 1 and 10")
    except ValueError:
        errors.append("quantity: must be a whole number")
    try:
        tip = Decimal(form.get("tip", "0"))
        if not Decimal("0") <= tip <= Decimal("500"):
            errors.append("tip: must be between 0 and 500 rupees")
    except InvalidOperation:
        errors.append("tip: must be a number")
    return errors


good = {"mobile": "+91 98765 43210", "pin": "302001", "state": "Rajasthan", "quantity": "2", "tip": "20"}
bad = {"mobile": "12345", "pin": "30200", "state": "Goa'; DROP TABLE orders;--", "quantity": "-3", "tip": "1e9"}
print(validate_order(good))
for problem in validate_order(bad):
    print(problem)
Output
[]
mobile: enter a 10-digit Indian mobile number
pin: enter a 6-digit PIN code
state: choose a state from the list
quantity: must be between 1 and 10
tip: must be between 0 and 500 rupees

fullmatch() checks the whole string, not just its start (see Regular Expressions). Note that "1e9" is a valid number to Decimal, which is why the range check matters as much as the format check. Validation is one layer; you still use ? placeholders for SQL (see SQLite Databases) and escape output in HTML templates.

Dangerous functions

A few Python features run code or touch the system, so they must never receive untrusted input:

RiskyWhySafer choice
eval(text), exec(text)Runs any Python code in the textjson.loads() or ast.literal_eval() for data
pickle.load() on received filesLoading a pickle can run arbitrary codeJSON or CSV for data from outside
subprocess.run(cmd, shell=True) with user textThe shell runs extra commands hidden in the textsubprocess.run(["ping", "-c", "1", host]) with a list, no shell
f-strings in SQLSQL injectionPlaceholders: execute("... WHERE id = ?", (order_id,))
yaml.load() from PyYAMLCan build arbitrary objectsyaml.safe_load()

ast.literal_eval() accepts only plain values such as numbers, strings, lists and dictionaries, and refuses anything that would call a function:

PythonRun it yourself
import ast

safe_text = "[450, 1299, 320]"
attack_text = "__import__('os').remove('orders.db')"

print(sum(ast.literal_eval(safe_text)))
try:
    ast.literal_eval(attack_text)
except ValueError as error:
    print("Rejected:", type(error).__name__)
Output
2069
Rejected: ValueError

Keep file paths inside their folder

If users can name a file, for example to download an invoice, a name like ../../.env can climb out of the intended folder. This is called path traversal. Resolve the full path and check that it is still inside the allowed folder:

PythonRun it yourself
from pathlib import Path

UPLOADS = Path("uploads").resolve()
UPLOADS.mkdir(exist_ok=True)
(UPLOADS / "invoice-101.pdf").write_text("demo", encoding="utf-8")


def safe_path(filename: str) -> Path:
    target = (UPLOADS / filename).resolve()
    if not target.is_relative_to(UPLOADS):
        raise PermissionError(f"blocked path: {filename}")
    return target


for name in ["invoice-101.pdf", "../secrets.env", "../../etc/passwd"]:
    try:
        print("OK:", safe_path(name).name)
    except PermissionError as error:
        print(error)
Output
OK: invoice-101.pdf
blocked path: ../secrets.env
blocked path: ../../etc/passwd

Even better, never use the user's text as a file name at all: store uploads under a random name from secrets.token_hex() and keep the original name in the database.

Dependency safety

Every package you install runs with your program's permissions, and so do all the packages it depends on. Keep that supply chain healthy:

  • Check names carefully. Attackers publish look-alike packages with typos of popular names (typosquatting). Copy install commands from the project's official documentation.
  • Lock versions with hashes. uv.lock records a hash for every file, so a swapped file is rejected (see Project Workflow with uv & ruff).
  • Scan for known vulnerabilities. pip-audit, a tool from the Python Packaging Authority, checks your installed packages against a public database of known security problems. Run uvx pip-audit or python -m pip install pip-audit and then pip-audit, and upgrade what it reports.
  • Keep Python itself updated, and prefer fewer, well-maintained dependencies.

Common mistakes

  • Hard-coding keys "just for testing". Test code gets committed too. Use environment variables from day one.
  • Using random for OTPs or tokens. Use secrets.
  • Hashing passwords with plain sha256 or MD5. Use a slow, salted password hash or your framework's built-in system.
  • Validating only in the browser. JavaScript checks are for convenience; attackers send requests directly. Validate on the server.
  • Logging full requests. Logs then contain passwords, OTPs and tokens. Mask or leave out sensitive fields.

Key points

  • Keep secrets in environment variables, check them at start-up, never print or commit them.
  • Use the secrets module for OTPs and tokens, never random.
  • Store passwords as salted, slow hashes and compare with hmac.compare_digest().
  • Validate every outside value with allow-lists, fullmatch() and range checks.
  • Never pass untrusted text to eval, exec, pickle, shell=True or f-string SQL.
  • Resolve file paths and check is_relative_to(); lock and audit your dependencies.

Exercise

Write a function make_download_link(order_id: str) -> str for an online shop. It must accept only order IDs of the form ORD- followed by exactly 6 digits (raise ValueError otherwise), and return a URL like https://shop.example.in/download/ORD-123456?token=... where the token is a fresh, unguessable value of at least 32 characters. Show that a valid ID works and that ORD-12 and ORD-123456/../../admin are rejected.

Show solution
PythonRun it yourself
import re
import secrets

ORDER_ID = re.compile(r"ORD-\d{6}")


def make_download_link(order_id: str) -> str:
    if not ORDER_ID.fullmatch(order_id):
        raise ValueError(f"invalid order id: {order_id!r}")
    token = secrets.token_urlsafe(32)
    return f"https://shop.example.in/download/{order_id}?token={token}"


link = make_download_link("ORD-123456")
print(link.startswith("https://shop.example.in/download/ORD-123456?token="))
print(len(link.split("token=")[1]) >= 32)

for bad in ["ORD-12", "ORD-123456/../../admin"]:
    try:
        make_download_link(bad)
    except ValueError as error:
        print(error)
Output
True
True
invalid order id: 'ORD-12'
invalid order id: 'ORD-123456/../../admin'

fullmatch() rejects the second bad ID even though it starts with a valid one, which match() or search() would have allowed. secrets.token_urlsafe(32) uses 32 random bytes, which become 43 URL-safe characters. In a real shop you would also store the token with an expiry time and check it when the link is used.

Quick Quiz

1. Where should an API key for an SMS service be kept?

2. Which module should generate a 6-digit OTP?

3. Why is hashlib.sha256(password) a poor way to store passwords?

4. What does target.is_relative_to(UPLOADS) protect against?

Learn Python with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call