Picture a locked door. It looks solid, reliable. But what if the lock is broken, or someone left a key under the mat? That’s often what we do in software. We build impressive applications with complex features, but sometimes we leave the digital equivalent of a key under the mat. I want to talk about how to stop doing that.
Security isn’t a magic spell or a separate box to check at the end. It’s the habit of questioning every single piece of information that comes into your program. It’s assuming that any data from the outside world is potentially hostile until you prove it’s safe. This shift in thinking is the real foundation.
Let’s start with the front door of your application: the point where it accepts data. A user types into a form. Another service sends an API call. A file gets uploaded. Our instinct is to use this data immediately. We should resist that instinct. First, we must check it, clean it, and make sure it’s exactly what we expect.
For example, if I ask for an email address, I shouldn’t just check if it has an ”@” symbol. I need to verify it’s a properly formatted email. If I ask for a comment, I need to ensure it’s a safe string of text, not hidden computer instructions. Here’s a simple way to think about it in code.
// Let's say I'm building a Node.js API endpoint.
// A request comes in with some user data.
function handleUserSignup(requestBody) {
const userData = requestBody;
// My first job is validation. Is this data shaped correctly?
if (typeof userData.name !== 'string' || userData.name.length > 100) {
throw new Error('Name must be a string under 100 characters.');
}
// For an email, I use a trusted library. Don't try to write this logic yourself.
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(userData.email)) {
throw new Error('Please provide a valid email address.');
}
// For their comment, I need to "escape" it.
// This means turning special characters into harmless text.
// If someone submits `<script>badCode()</script>`, it becomes a plain string, not executable code.
const safeComment = userData.comment
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
// Now, `safeComment` is safe to store in a database or show on a webpage.
return { name: userData.name, email: userData.email, comment: safeComment };
}
This is input validation and output encoding. It’s simple, but it blocks a huge number of problems right at the gates.
One of the oldest and most dangerous problems is called SQL Injection. It happens when we carelessly mix user data with database commands. Imagine I have a login function. A user types their username. My naive code might build a query like this:
# THIS IS THE DANGEROUS WAY. NEVER DO THIS.
username = request.form['username']
query = f"SELECT * FROM users WHERE username = '{username}'"
If a user enters admin' --, the query becomes SELECT * FROM users WHERE username = 'admin' --'. The -- is a SQL comment, so it ignores the rest. They just logged in as admin. It can be much worse, allowing attackers to read, modify, or delete all your data.
The fix is simple and non-negotiable. We use parameterized queries, also called prepared statements. We tell the database the command structure first, then give it the data separately. The database knows not to mix them up.
# This is the safe way.
import sqlite3
conn = sqlite3.connect('app.db')
cursor = conn.cursor()
username = request.form['username']
# The ? is a placeholder. We send the username value separately.
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
user = cursor.fetchone()
# The database driver handles the escaping. It ensures `username` is always treated as data, not code.
This pattern works in every language. In Java, you use PreparedStatement. In PHP, you use PDO with parameters. In C#, you use SqlCommand with Parameters.Add. The syntax changes, but the principle is constant: separate the command from the data.
A related issue happens in the user’s browser, called Cross-Site Scripting, or XSS. Here, an attacker manages to inject their own JavaScript code into a webpage that other users see. If I build a forum and don’t sanitize comments, someone could post a comment like: <script>sendMyCookiesToHackerServer()</script>. When you load the page, your browser runs that script, and your private session cookie could be stolen.
The defense is output encoding. Before putting any user-generated content onto an HTML page, we must encode it. We turn < into < and & into &. This makes the browser display the code as harmless text.
Modern web frameworks often do this automatically, but you must know when they do and when they don’t. For instance, React escapes content by default when you use curly braces { }. But if you ever use dangerouslySetInnerHTML, you bypass this protection. The name is a warning for a reason.
// In a React component, this is generally safe:
<p>{userComment}</p> // The comment is automatically encoded.
// This is extremely dangerous:
<div dangerouslySetInnerHTML={{ __html: userComment }} /> // You are responsible for safety here.
If you are generating HTML on the server, you must be explicit. In a language like Java with JSP, you’d use the JSTL <c:out> tag, which performs escaping.
<!-- Safe -->
<p><c:out value="${userInput}" /></p>
<!-- Dangerous -->
<p>${userInput}</p>
The rule is: always contextually encode. Encode for HTML when putting data into HTML. Encode for JavaScript if you’re putting data into a <script> block. Encode for a URL if you’re using it in a link parameter.
Next, let’s talk about how we prove who someone is. Authentication. My biggest piece of advice here is: do not build this yourself. Use a well-established, audited library. The number of ways to get password handling wrong is staggering.
When a user creates a password, you must never, ever store the actual password. You store a “hash” of it. A hash is a one-way mathematical function. You can turn “myPassword123” into a long string of gibberish, but you can’t turn the gibberish back into “myPassword123”. When the user logs in, you hash the password they type and compare it to the stored hash.
But not all hash functions are equal for passwords. MD5 and SHA-1 are fast and were designed for file verification, not passwords. Attackers can compute billions of these per second. For passwords, we use slow, computationally expensive functions deliberately. bcrypt, scrypt, or Argon2 are the current standards.
Here’s how it looks in Go:
import "golang.org/x/crypto/bcrypt"
// When a user signs up:
func createUser(password string) {
// The second argument (12) is the "cost factor". Higher is slower and more secure.
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
panic(err) // Handle this properly in real code!
}
storeInDatabase(string(hashedBytes))
}
// When a user logs in:
func login(username, attemptedPassword string) bool {
storedHash := getHashFromDatabase(username)
err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(attemptedPassword))
// If err is nil, the password matches.
return err == nil
}
Notice I don’t handle any of the complex math. The library does it for me. My job is to call it correctly and keep the cost factor appropriate for my hardware.
Once logged in, we give the user a session, usually a cookie. This cookie must be protected. Set the HttpOnly flag so JavaScript can’t read it (blocking some XSS attacks). Set the Secure flag so it’s only sent over HTTPS. Use the SameSite attribute to help prevent a related attack called Cross-Site Request Forgery (CSRF).
CSRF is a tricky one. Imagine you’re logged into your bank’s website. Then, you visit a malicious site. That site contains a hidden form that submits a “transfer money” request to your bank. Your browser, seeing you’re logged into the bank, sends your session cookie with that request. The bank sees a valid session and executes the transfer. You never intended it.
The defense is an anti-CSRF token. When my server sends a web form, I include a unique, secret token as a hidden field. When the form is submitted back, my server checks that the token matches what it expects. The malicious site can’t know this token, so its forged request fails.
// A very basic example in plain PHP
session_start();
// Generate a token once per session for forms
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Place it in every state-changing form
echo '<form action="/transfer" method="POST">';
echo '<input type="hidden" name="csrf_token" value="' . $_SESSION['csrf_token'] . '">';
// ... other form fields ...
echo '</form>';
// When processing the form
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
die('Invalid request.');
}
// Proceed with the transfer...
Most modern web frameworks (like Spring Security, Django, Rails) have built-in CSRF protection. Your job is to ensure it’s enabled.
Another critical area is how we handle data that comes in a structured, serialized format. Serialization turns an object in memory into a string for storage or transmission. Deserialization turns that string back into an object. It’s powerful but dangerous. An attacker can craft a malicious serialized string that, when deserialized, makes your application run arbitrary code.
The safest advice is to avoid deserializing data from untrusted sources altogether. Use simpler, safer formats like JSON and validate the data thoroughly before you work with it.
// In C#, using System.Text.Json (safe by default for common scenarios)
public class UserProfile
{
public string Username { get; set; }
public int Age { get; set; }
}
string jsonFromUser = GetJsonInput();
// Deserialize with validation options
var options = new JsonSerializerOptions
{
// Limit how deep the object graph can be
MaxDepth = 5,
// Don't automatically convert property names
PropertyNameCaseInsensitive = false
};
try
{
UserProfile profile = JsonSerializer.Deserialize<UserProfile>(jsonFromUser, options);
// Even after deserialization, validate the business logic
if (profile.Age < 0 || profile.Age > 120)
{
throw new ArgumentException("Invalid age.");
}
// Use the profile...
}
catch (JsonException)
{
// The input wasn't valid JSON for our UserProfile class.
// Log this and reject the request.
}
A critical vulnerability often comes not from our code, but from how we set things up. This is security misconfiguration. It includes leaving default admin passwords in place, having overly detailed error messages, leaving debugging features on in production, or running software with unnecessary high-level permissions.
Apply the “principle of least privilege.” A process should have only the permissions it absolutely needs to function. If my application only needs to read a database, it should not have permissions to delete tables. If it’s a web server, it shouldn’t run as the “root” user on the system.
This principle extends to containers and cloud services. Don’t run your Docker container as root inside the container. Create a specific user.
# Example Dockerfile snippet
FROM node:18-alpine
# Create a non-root user and group
RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -D appuser
# Copy app files
COPY --chown=appuser:appgroup . /app
WORKDIR /app
# Switch to the non-root user
USER appuser
# Start the app
CMD ["node", "server.js"]
This limits the damage if someone manages to break into the application.
Our applications are built on mountains of other people’s code: our dependencies. A vulnerability in one of those libraries is a vulnerability in our app. We must manage them actively.
Use tools to check for known vulnerabilities. Update your dependencies regularly. But don’t auto-update to the latest version in production without testing; updates can break things. Have a process for reviewing and applying security patches.
# Example of a GitHub Actions workflow that checks for vulnerabilities
name: Security Checks
on: [push, pull_request]
jobs:
depcheck:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Run npm audit
run: npm audit --audit-level=high
# This will fail the build if high-severity vulnerabilities are found
- name: Run OWASP Dependency-Check
uses: dependency-check/Dependency-Check_Action@main
with:
project: 'My App Security Scan'
format: 'HTML'
args: '--failOnCVSS 8' # Fail on critical vulnerabilities
When things go wrong, we must handle errors gracefully. The error messages we show users and the ones we log internally are very different. A detailed error message shown to a user can be a goldmine for an attacker.
Never leak stack traces, database schema details, or server version information in a public error page. Log those details internally for your team to debug.
# Ruby on Rails example
# In a controller action
begin
# Some risky operation...
process_payment(params[:amount])
rescue PaymentGatewayError => e
# Log the full technical details for ourselves
Rails.logger.error("Payment failed for user #{current_user.id}: #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
# Show a generic, friendly message to the user
redirect_to checkout_path, alert: "Sorry, we couldn't process your payment. Please try again or contact support."
end
Finally, let’s consider file uploads. They are a necessary feature but a significant risk. An attacker could upload a malicious script, try to overwrite a critical system file, or upload a gigantic file to fill up your disk.
We need a strict process:
- Validate the file type by its content (MIME type), not just its filename extension.
- Limit the file size.
- Give it a new, random name when storing it (to prevent path traversal attacks).
- Store it outside the publicly accessible web directory.
- Serve it through a secure controller that checks permissions.
// Node.js/Express with Multer example
import * as fs from 'fs/promises';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
// Configure upload middleware (size limits, etc.)
const upload = multer({
dest: 'temp_uploads/',
limits: { fileSize: 5 * 1024 * 1024 } // 5MB
});
app.post('/upload', upload.single('avatar'), async (req, res) => {
if (!req.file) {
return res.status(400).send('No file uploaded.');
}
// 1. Check MIME type
const allowedTypes = ['image/jpeg', 'image/png'];
if (!allowedTypes.includes(req.file.mimetype)) {
await fs.unlink(req.file.path); // Delete temp file
return res.status(400).send('File type not allowed.');
}
// 2. File size is already limited by multer.
// 3. Generate a safe, random filename
const fileExtension = path.extname(req.file.originalname).toLowerCase();
const safeFilename = `${uuidv4()}${fileExtension}`;
// 4. Define final storage path *outside* the web root.
const finalPath = path.join('/secure/storage/path', safeFilename);
// Move file from temp to secure storage
await fs.rename(req.file.path, finalPath);
// 5. Store only `safeFilename` in your database, linked to the user.
// To serve it later, you'd have a separate, secure route like `/file/:id`
// that checks if the logged-in user is allowed to see it before streaming the file.
res.send({ success: true, fileId: safeFilename });
});
Building secure software is a continuous process of questioning and verifying. It’s about adopting a cautious mindset. I make it a habit to ask, with every function I write: “What data comes in here? Do I trust it? Where does this data go? Could it be misinterpreted?”
Automated security scanners and code analysis tools are invaluable helpers. They can catch common mistakes. But they can’t replace a developer who understands the principles. The most effective tool is a simple code review with a security focus. Having a second pair of eyes ask “why is this data trusted?” can find problems that machines miss.
Start by focusing on the big four: validate all input, use parameterized queries, encode all output, and handle authentication with proven libraries. Get those right, and you’ve already built a fortress compared to most applications. Then, layer on the other practices. Security isn’t a destination you reach; it’s the way you write code, every single day.