Part 2 — “Okay, But How Do We Actually Write Secure Code?”

In the previous post, we looked at how to think about security.
We talked about the CIA triad, un-trusted input, security principles, threat modelling and STRIDE.
But knowing that SQL Injection exists doesn't automatically make our application secure.
So let's get practical.
What does secure coding actually look like?
Let's follow a request through an application and see where things can go wrong — and what we can do about it.
First things first: Who is allowed to do this?
Let's say we have an API:
GET /users/123/orders
The user is logged in.
So we check:
Is the user authenticated?
↓
YES
↓
Return the orders
Looks good?
Not necessarily.
What happens if I change the URL?
GET /users/124/orders
And suddenly I can see someone else's orders?
We have a problem.
I am authenticated.
I just wasn't authorized to access that resource.
This gives us a very important distinction:
Authentication
↓
"Who are you?"
Authorization
↓
"What are you allowed to do?"
Vertical access control
Different users have different levels of privilege.
Admin
↓
Employee
↓
Guest
An employee shouldn't be able to perform administrative operations just because they are logged in.
Horizontal access control
Users may have the same role but different resources.
User A → Account A
User B → Account B
User A shouldn't be able to access User B's account simply by changing an ID in the URL.
So the rule is:
Authentication tells us who the user is. Authorization tells us what they can do.
And authorization checks should happen on the server, for every protected operation.
This is our Principle of Least Privilege in action.
Don't trust input
Now let's talk about one of the most important ideas in secure coding:
Never trust data coming from outside your trust boundary.
It doesn't matter whether it came from:
a browser
a mobile application
another API
a partner service
a file
a message queue
It is still input.
And input can be:
Malformed
Accidental
Unexpected
Malicious
Let's see what happens when we forget this.
SQL Injection
Suppose we want to find a user.
We write:
query := "SELECT * FROM users WHERE name = '" + username + "'"
Seems straightforward.
We take the username and put it into our query.
But there's a fundamental problem.
We're assuming that username is data.
The database doesn't necessarily see it that way.
An attacker may be able to manipulate the input so that it changes the meaning of the SQL query itself.
And suddenly:
Expected:
User input → DATA
Attack:
User input → SQL INSTRUCTION
That's SQL Injection.
The solution isn't:
"Let's block a few suspicious characters."
Blacklists are notoriously fragile.
Instead, separate the query structure from the data.
Use parameterized queries / prepared statements.
Conceptually:
SQL structure
+
User data
rather than:
SQL + User data
The database can then distinguish between what is supposed to be SQL and what is supposed to be data.
SQL isn't the only interpreter
This is where things get interesting.
Our application constantly sends data to things that interpret that data.
Untrusted Input
│
┌────────────┼────────────┐
↓ ↓ ↓
SQL Browser Shell
↓ ↓ ↓
Injection XSS Command
Injection
The general pattern is:
Untrusted data
↓
Interpreter
↓
Unexpected behaviour
That's why injection isn't just an SQL problem.
Cross-Site Scripting — when data becomes code
Imagine a user submits some content to our application.
We store it.
Later, another user opens the page.
If we insert the stored content directly into the page without appropriate handling, the browser may interpret part of that content as HTML or JavaScript.
The browser doesn't know:
"Oh, this came from another user. I'll treat it as harmless."
It simply interprets what we give it according to the browser's rules.
And that's Cross-Site Scripting (XSS).
Again, notice the pattern:
User input
↓
Application
↓
Browser
↓
Input gets interpreted as code
The right defence isn't simply:
"Block
<and>."
That is too simplistic.
Instead, we should use:
Context-aware output encoding
Framework-provided escaping
Safe DOM APIs
Sanitization when HTML genuinely needs to be allowed
Content Security Policy as an additional layer
The principle is:
Treat data as data. Don't accidentally turn it into executable instructions.
Before encrypting data, ask: Do we need to store it?
Now let's talk about Cryptographic Failures.
When we think about protecting sensitive information, our first instinct is often:
"Let's encrypt it."
But there's a better question to ask first:
Do we even need to store it?
If we don't need sensitive information, not collecting it is often safer than trying to protect it forever.
If we do need it, we need to think about where it is going.
Data in transit
Client ─────────→ Server
We need to protect the communication channel.
That's where TLS comes in.
Data at rest
Application
↓
Database
↓
Sensitive information
Sensitive information may need appropriate protection at rest.
But here's another important distinction:
Encryption ≠ Hashing
If we store a password, we don't need to decrypt it later.
We need to verify whether the password supplied during login matches the stored verifier.
That's why passwords should be stored using an appropriate password hashing algorithm rather than reversible encryption.
Cryptography also brings questions around:
Key management
Key rotation
Encryption algorithms
Data retention
Access to encryption keys
And again, the first question remains:
What data do we actually need to keep?
Your code can be secure and your application can still be insecure
This one is easy to miss.
Imagine we've written beautiful, secure application code.
Then we deploy Redis directly to the public internet.
Internet
↓
Redis
Oops.
Or perhaps:
DEBUG=true
is enabled in production.
Or the application still has default credentials.
Or an internal admin endpoint is publicly accessible.
Or a service has permissions far beyond what it needs.
Our source code might be perfectly reasonable.
The system is still insecure.
This is Security Mis-configuration.
Some examples include:
Default credentials
Debug mode in production
Unnecessary services
Excessive permissions
Insecure CORS configuration
Publicly exposed internal services
Open network ports
Secrets stored insecurely
This is why one of the principles from Part 1 matters so much:
Secure by default.
The default configuration should make the secure choice the easy choice.
"But I didn't write that code..."
This is probably the most frustrating part of modern software development.
You didn't write every line running in your application.
You imported it.
Our application might look something like:
My Application
│
├── Library A
│ └── Library X
│
├── Framework B
│ └── Library Y
│
└── Package C
└── Library Z
If one of those dependencies has a known vulnerability, our application may be affected too.
This is why we need:
Dependency scanning
Tools can scan dependencies for known vulnerabilities.
Software Bill of Materials
An SBOM gives us an inventory of the software components we're shipping.
Something like:
Application
├── React
├── Go
├── Library A v1.x
├── Library B v2.x
└── Library C v3.x
The bigger question is:
Do we actually know what's inside the software we're shipping?
This is especially important as dependency trees become deeper and software supply chains become more complicated.
Authentication: Don't try to be clever
Let's come back to authentication.
The simplest authentication mechanism is:
Username + Password
But passwords can be:
guessed
reused
stolen
leaked
phished
An attacker doesn't necessarily need to "hack" your password database.
They may simply obtain credentials from another breach and try them against your application.
This is credential stuffing.
So we add additional protections:
Authentication
│
├── Strong password handling
├── Rate limiting
├── Brute-force protection
├── Secure password reset
├── Session security
└── MFA
MFA adds another layer because knowing a password alone isn't enough.
But here's an important engineering principle:
Don't roll your own authentication system unless you absolutely have to.
Authentication and identity protocols have a lot of subtle edge cases.
Use mature libraries and established standards such as OAuth 2.0 and OpenID Connect where appropriate.
Being clever with authentication is usually a bad idea.
What if the code works perfectly?
Now we get to my favourite part.
Not every security vulnerability is a coding mistake.
Sometimes the code works exactly as we programmed it to.
And that's the problem.
Imagine we have a money-transfer API:
POST /transfer
from = Account A
to = Account B
amount = ₹10,000
We validate the input.
We authenticate the user.
We check authorization.
We use parameterized SQL.
Everything looks great.
But what happens if two requests arrive at almost exactly the same time?
Request 1 → Check balance → ₹10,000 available
Request 2 → Check balance → ₹10,000 available
Request 1 → Transfer ₹10,000
Request 2 → Transfer ₹10,000
Depending on how the system is implemented, we could end up transferring more money than the account actually had.
This is a race condition.
The code may have no obvious syntax error.
The API may work perfectly during normal testing.
The vulnerability is in the logic of the system.
Secure design matters
This is why security isn't only about:
"Can someone break my SQL query?"
We also need to ask:
"Can someone make the system behave in a way the business never intended?"
Think about an e-commerce application.
Suppose an item has:
Inventory = 1
Two users click "Buy" at almost the same time.
If our system isn't designed correctly:
User A → sees 1 item
User B → sees 1 item
User A → buys it
User B → buys it
Now we have sold one item twice.
Other examples of logic vulnerabilities include:
Race conditions
Double spending
Replay attacks
Manipulating prices
Manipulating inventory
Bypassing approval workflows
Performing operations in an unexpected sequence
This is where threat modelling becomes useful.
Before implementing a feature, ask:
How could someone misuse this feature?
Not just:
"How should this feature work?"
Software integrity
So far we've mostly talked about protecting the application from attackers.
But what if the software itself is compromised?
Imagine:
Developer account compromised
↓
Malicious code committed
↓
CI/CD pipeline
↓
Production
The application can have excellent runtime security.
It doesn't matter.
The thing we're deploying has already been compromised.
This is where software integrity becomes important.
We can use mechanisms such as:
Code reviews
Protected branches
Restricted CI/CD permissions
Dependency integrity checks
Artifact verification
SBOMs
Separation of duties
This is also where the principle from Part 1 comes back:
Don't give one person or one system more power than it needs.
Even a trusted developer account should have guardrails.
Because security isn't based on the assumption that:
"Everyone is always trustworthy."
It's based on designing systems that remain resilient even when something goes wrong.
Security needs a memory
Now imagine an account gets compromised.
Can we answer:
Who logged in?
When?
From where?
What did they access?
What did they change?
When did it start?
If the answer is:
"We don't know."
we have another problem.
That's where security logging and monitoring comes in.
We may want to track events such as:
Login success
Login failure
Password reset
MFA success
MFA failure
Email address change
Password change
Permission change
Suspicious activity
But there's an important balance here.
Logging everything isn't automatically good security.
We don't want our logs to become another place where sensitive information leaks.
Don't casually log:
Passwords
Access tokens
Secrets
Sensitive personal information
The goal is:
Log enough information to reconstruct important security events without creating another confidentiality problem.
When your server becomes the attacker
Let's finish with something slightly more interesting.
Imagine our application provides an API that fetches a URL:
POST /fetch-url
{
"url": "https://example.com"
}
Our server makes the request.
Seems harmless.
But now imagine the attacker provides a URL pointing somewhere that the attacker cannot access directly.
The architecture becomes:
Attacker
↓
Your Application
↓
Internal Service
The attacker isn't directly accessing the internal service.
Your trusted server is doing it on their behalf.
This is Server-Side Request Forgery (SSRF).
And it teaches us another important security lesson:
A request made by your server doesn't automatically become trustworthy just because your server made it.
We need to carefully control what destinations our application is allowed to access.
Depending on the use case, that can involve:
URL validation
Allowlisting destinations
Restricting outbound network access
Blocking access to sensitive internal endpoints
Protecting cloud metadata services
Again, we're protecting a trust boundary.
Can we automate all of this?
At this point you might be thinking:
"Am I supposed to manually check all of this every time I write code?"
Thankfully, no.
We can automate a lot of security checks.
This is where Shift Left comes in.
Instead of:
Write code
↓
Deploy
↓
Security team finds vulnerability 😬
we want something closer to:
Design
↓
Threat Model
↓
Code
↓
SAST
↓
Dependency Scanning
↓
Tests
↓
DAST
↓
Deploy
↓
Monitor
SAST
Static Application Security Testing
The tool examines source code for potential security issues.
For example:
Semgrep
DAST
Dynamic Application Security Testing
Instead of examining source code, DAST tools interact with the running application and look for vulnerabilities.
For example:
OWASP ZAP
Burp Suite
Dependency scanning
Checks whether the libraries we're using have known vulnerabilities.
WAF
A Web Application Firewall can sit in front of our application and detect or block certain malicious requests.
But remember:
A WAF is another layer of defence.
It is not an excuse to write insecure code.
That's Defence in Depth again.
Bringing it all together
We've covered quite a few things:
Broken Access Control
↓
Cryptographic Failures
↓
Injection
↓
Authentication Failures
↓
Security Misconfiguration
↓
Vulnerable Components
↓
Secure Design
↓
Software Integrity
↓
Logging & Monitoring
↓
SSRF
↓
Security Automation
At first, these look like ten completely different problems.
But they're really different manifestations of the same underlying problem:
We're building systems that interact with things we cannot completely trust.
Users can be malicious.
Credentials can be stolen.
Dependencies can contain vulnerabilities.
Configurations can be wrong.
Networks can be compromised.
Trusted accounts can be compromised.
And our own assumptions about how users will interact with our system can be wrong.
That's why secure coding isn't a checklist we complete before shipping.
It's a way of thinking about software throughout its entire lifecycle:
Design
↓
Implement
↓
Test
↓
Deploy
↓
Monitor
↓
Improve
Security has to exist at every stage.
Because ultimately, secure software isn't software that has zero vulnerabilities.
It's software that is deliberately designed to:
reduce the probability of vulnerabilities, limit their impact when they occur, detect what happened, and recover from it.
And that is probably the most important mindset shift in secure coding:
Don't ask only, "How do I prevent the attack?"
Ask:
"What happens if this layer fails?"
That's how we start building systems that are actually resilient.



