Skip to main content

Command Palette

Search for a command to run...

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

Updated
14 min readView as Markdown
Part 2 — “Okay, But How Do We Actually Write Secure Code?”
V
Recursive Thinker

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.