Secure Coding Practices: A Developer Guide to the Failures That Actually Ship background
Back to Journal
Application Security

Secure Coding Practices: A Developer Guide to the Failures That Actually Ship

Peyush Baranwal
September 3, 2026
22 min read

Eight failure classes with vulnerable and fixed code side by side, the habits that prevent whole categories at once, and where AI genuinely helps developers write secure code.

Almost no developer ships SQL injection because they have never heard of SQL injection. They ship it because a string template was the fastest way to get the query working, the code review was about the feature, and nothing in the pipeline objected. Secure coding is far less about knowing forty vulnerability classes than about arranging your codebase so the secure way is also the easy way. This is a working guide to the failures that actually reach production, with the fix for each, the habits that prevent whole categories at once, and an honest account of where AI helps — and where it quietly makes things worse.

What this covers

  • — What actually breaks, per OWASP's 2025 data, and where to spend your attention
  • — Eight failure classes with vulnerable and fixed code side by side
  • — Five habits that prevent more than any checklist
  • — Where AI genuinely helps developers write secure code, and its two failure modes
  • — What to gate in CI versus what to merely warn about

Start from what actually breaks

OWASP published a new Top 10 in 2025, its first revision since 2021, and the ordering should change where you spend review time. Broken Access Control is A01, and the supporting data is worth internalising: OWASP found 100% of the applications tested had some form of broken access control, across 40 mapped CWEs, 1,839,701 occurrences and 32,654 associated CVEs — the highest occurrence count of any category.

Meanwhile Injection has moved down to A05, and two categories are newly prominent: Software Supply Chain Failures at A03 and Mishandling of Exceptional Conditions at A10. The practical read for a developer: the classic input-sanitisation reflex matters less than it used to, and the questions "is this caller allowed to do this?" and "what happens when this fails?" matter more.

If you only change one habit. Every time you write a handler that loads a record by an identifier from the request, ask who is allowed to see that record and whether the code actually checks. That single question addresses the category present in every application OWASP measured — and it is the one a linter cannot find for you, because the rule lives in your product, not in your syntax.

The principle: make the secure path the default path

Developer training that relies on remembering to do the safe thing fails at scale, because it competes with deadlines and it has to win every single time. The interventions that actually hold are the ones where the insecure version is harder to write than the secure one — a query builder that will not concatenate, a template engine that escapes by default, an authorisation helper that must be called because the data layer refuses to return rows without a tenant scope.

Read the sections below with that lens. For each failure class the individual fix matters, but the durable answer is the structural one underneath it.

A01 — Broken access control

The number-one category, and the one that most often survives a penetration test because the code "works". The canonical form: a handler trusts an identifier from the request and never checks ownership.

Vulnerable — IDOR / BOLA

// Express. Authenticated, and still broken.

app.get('/api/invoices/:id', requireAuth, async (req, res) => {

  const invoice = await db.invoice.findUnique({

    where: { id: req.params.id },

  });

  res.json(invoice);   // any logged-in user can read any invoice

});

Authentication answered who are you. Nothing answered may you have this. The naive fix is an ownership check inside the handler:

Better — scope the query, do not filter after

app.get('/api/invoices/:id', requireAuth, async (req, res) => {

  const invoice = await db.invoice.findFirst({

    where: {

      id: req.params.id,

      organisationId: req.user.organisationId,   // scope is part of the query

    },

  });

  if (!invoice) return res.sendStatus(404);      // 404, not 403 — do not confirm existence

  res.json(invoice);

});

Note two details. The tenant scope is inside the query rather than an if after the fetch — a post-fetch check is one early return away from being bypassed, and it still leaks timing and existence. And the response is 404 rather than 403, because 403 confirms the record exists.

But that fix has to be repeated in every handler, which means it will eventually be forgotten. The structural version pushes scope into the data layer so an unscoped read is not expressible:

Durable — a repository that cannot return unscoped rows

// The only way to reach invoices is through a caller-bound repository.

function invoicesFor(user) {

  const scope = { organisationId: user.organisationId };

  return {

    byId: (id) => db.invoice.findFirst({ where: { ...scope, id } }),

    list: (where = {}) => db.invoice.findMany({ where: { ...where, ...scope } }),

  };

}



app.get('/api/invoices/:id', requireAuth, async (req, res) => {

  const invoice = await invoicesFor(req.user).byId(req.params.id);

  if (!invoice) return res.sendStatus(404);

  res.json(invoice);

});

Now forgetting the scope is not a silent bug — there is no API that lets you forget it. In PostgreSQL you can push the same guarantee further down with row-level security, so even a hand-written query obeys the tenant boundary.

The second common form is missing function-level authorisation: the endpoint checks that you are logged in but not that you hold the role the operation requires. Enforce roles declaratively at the route, never by reading a flag the client sent:

Vulnerable — client-supplied privilege

if (req.body.isAdmin) { await deleteAllRecords(); }        // trusts the request body



// and mass assignment, the same mistake wearing a different hat:

await db.user.update({ where: { id }, data: req.body });    // body may contain { role: 'admin' }

Fixed — server-side role, allow-listed fields

app.post('/api/admin/purge', requireAuth, requireRole('admin'), handler);



// never spread a request body into a write; pick the fields you accept

const { displayName, timezone } = req.body;

await db.user.update({

  where: { id: req.user.id },                 // the caller's own id, not one from input

  data: { displayName, timezone },

});

A05 — Injection

Lower in the table than it used to be, but still trivially fatal. The rule is the same across every interpreter: never build a command out of string concatenation. Pass data as data.

Vulnerable — SQL, and a command

# Python / psycopg

cur.execute("SELECT * FROM users WHERE email = '" + email + "'")

cur.execute(f"SELECT * FROM users WHERE email = '{email}'")     # f-string, same bug



# shell out with user input anywhere in the string

os.system("convert " + filename + " out.png")

Fixed — parameters, and argument vectors

# the driver sends the value out of band; quoting is not your problem

cur.execute("SELECT * FROM users WHERE email = %s", (email,))



# no shell, explicit argv, so metacharacters are inert

subprocess.run(["convert", filename, "out.png"], shell=False, check=True)

Two traps worth naming. An ORM does not make you safe if you reach for its raw escape hatch — queryRaw, .extra(), literal() and friends are string concatenation with better branding, and they need parameters too. And identifiers cannot be parameterised: if a sort column or table name comes from input, validate it against an allow-list, because no placeholder will help you.

Fixed — dynamic sort column via allow-list

const SORTABLE = new Set(['created_at', 'amount', 'status']);

const column = SORTABLE.has(req.query.sort) ? req.query.sort : 'created_at';

const dir = req.query.dir === 'asc' ? 'ASC' : 'DESC';   // never interpolate this either

For XSS, the thing to understand is that escaping is context-dependent. React, Angular and Vue escape text nodes for you, which removes the common case — the remaining risk sits in the escape hatches and in non-HTML contexts:

Vulnerable — the framework's escape hatches

<div dangerouslySetInnerHTML={{ __html: comment.body }} />   {/* stored XSS */}

element.innerHTML = userInput;

<a href={userSuppliedUrl}>                                    {/* javascript: URLs */}

Fixed — sanitise on render, validate URL schemes

import DOMPurify from 'dompurify';



// if you must render authored HTML, sanitise at render time, not on save

<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment.body) }} />



// and gate URL schemes explicitly

const safeHref = /^https?:///i.test(url) ? url : '#';

Sanitise on render rather than on save. Sanitising on save bakes one library version's idea of safety into your database permanently, and you cannot re-sanitise history when a bypass is published.

A04 — Cryptographic failures

Three failures cover most of what we find: passwords hashed with a fast algorithm, secrets in the repository, and JWTs verified carelessly.

Vulnerable

hash = hashlib.sha256(password.encode()).hexdigest()   # fast = brute-forceable

if user.token == provided_token:                       # timing-comparable

API_KEY = "sk_live_51H..."                             # committed

jwt.decode(token, key, options={"verify_signature": False})

Fixed

from argon2 import PasswordHasher

import hmac, os, jwt



ph = PasswordHasher()                 # argon2id; bcrypt or scrypt are also fine

hash = ph.hash(password)              # deliberately slow, salted for you



hmac.compare_digest(user.token, provided_token)   # constant time



API_KEY = os.environ["API_KEY"]       # injected, never committed



# pin the algorithm — otherwise you accept whatever the token claims

jwt.decode(token, key, algorithms=["RS256"], audience=EXPECTED_AUD)

That last line matters more than it looks. If you do not pin algorithms, a library may honour the alg header in the token — and an attacker who can choose the algorithm can sometimes get a token accepted that you never signed. Pin the algorithm, check the audience, check the issuer.

A07 — Authentication failures

Use your framework's session or identity provider rather than writing this yourself. The two places teams still hand-roll and get wrong are password reset and session invalidation.

Password reset — the properties that matter

import crypto from 'node:crypto';



const raw = crypto.randomBytes(32).toString('base64url');          // CSPRNG, not Math.random

const stored = crypto.createHash('sha256').update(raw).digest();   // store the hash only



await db.resetToken.create({ data: {

  userId, tokenHash: stored,

  expiresAt: new Date(Date.now() + 15 * 60_000),                   // short window

  usedAt: null,                                                     // single use

}});



// email the raw token; on redemption: look up by hash, reject if expired or used,

// mark used, then invalidate every existing session for that user.

Also: respond identically whether or not the address exists, or the endpoint becomes an account-enumeration oracle. And set cookies as HttpOnly; Secure; SameSite=Lax — a token readable from JavaScript turns any XSS into full account takeover.

A03 — Software supply chain failures

New at A03, and mostly not a coding problem — it is a dependency-hygiene problem, which makes it easy to ignore until a transitive package is compromised. The controls that pay for themselves:

ControlWhy it matters
Commit the lockfile; build with npm ciInstalls exactly what you tested. npm install can silently resolve something newer
Pin CI actions to a commit SHAA mutable tag on a third-party action is remote code execution in your pipeline, with your secrets
Disable install scripts where you canMost published malware executes on install, before any of your code runs
Generate an SBOM per releaseWhen the next widely-used package is compromised, the question is "are we affected?" — an SBOM answers it in minutes
Review new dependencies like new hiresMaintainer count, release cadence, whether it needs postinstall or network access at build time

We covered the inventory side of this in the SBOM guide, and a live example of install-time malware in this Packagist incident.

A08 — Integrity failures, and deserialisation

The rule is short: never deserialise untrusted input into arbitrary types. Formats that can reconstruct objects can usually be persuaded to execute code.

Vulnerable

pickle.loads(request.data)              # arbitrary code execution by design

yaml.load(user_yaml)                    # unsafe loader constructs objects

JSON.parse(x, reviver)                  # fine, but do not then trust the shape

Fixed — data formats, and validate the shape

yaml.safe_load(user_yaml)               # scalars, lists, dicts only



// parse, then validate against a schema before anything touches it

const Body = z.object({

  amount: z.number().int().positive().max(1_000_000),

  currency: z.enum(['INR', 'USD', 'CAD', 'AUD']),

});

const parsed = Body.safeParse(req.body);

if (!parsed.success) return res.status(400).json({ error: 'invalid body' });

A10 — Mishandling exceptional conditions

New to the 2025 list and genuinely under-discussed. The pattern is a security control that fails open: when the dependency it needs is unavailable, the code swallows the error and continues as if the check passed.

Vulnerable — the catch that grants access

let allowed = true;

try {

  allowed = await authz.check(user, resource);

} catch (e) {

  console.error(e);          // authz service down => everyone is allowed

}

if (allowed) return grant();

Fixed — deny by default, and say so out loud

let allowed = false;                       // default deny

try {

  allowed = await authz.check(user, resource);

} catch (err) {

  log.error({ err, userId: user.id, resource }, 'authz_unavailable');

  return res.status(503).json({ error: 'authorisation unavailable' });

}

if (!allowed) return res.sendStatus(404);

return grant();

Related and equally common: returning stack traces or driver errors to the client. Log the detail server-side with a correlation id; send the client the id and nothing else.

A09 — Logging you will actually want during an incident

Two failures here: too little to reconstruct what happened, and too much of the wrong thing.

Log the security events, redact the payloads

// worth logging: authn success and failure, authz denials, role and permission

// changes, password and MFA changes, bulk exports, admin actions.

log.warn({ event: 'authz_denied', userId, resource, requestId }, 'denied');



// never log: passwords, tokens, session ids, full card numbers, OTPs,

// government identifiers, or whole request bodies from authenticated routes.

const redact = ['req.headers.authorization', 'req.body.password', 'req.body.otp'];

Logs from authenticated routes usually contain personal data, which makes retention and access a privacy question as much as a security one — relevant under DPDP, GDPR and most contractual regimes.

Five habits that beat any checklist

1. Validate at the boundary, with a schema, allow-list style

One parse at the edge into a typed object, rejecting anything unexpected. Deny-lists of "bad characters" fail because you are guessing the attacker's alphabet.

2. Make the caller's identity part of every data access

If a repository method can be called without a subject, someone will call it that way. Bind scope in the constructor, not in the handler.

3. Default deny, and fail closed

Initialise permission flags to false. Make the error path refuse the operation. A control that fails open is worse than no control, because it is trusted.

4. Keep secrets out of code, and rotate what leaks

Environment or a secret manager, a pre-commit scanner, and a rotation runbook. A committed secret is compromised the moment it is pushed — deleting the line does not undo it, because the object stays in git history.

5. Write the abuse case next to the test case

For every feature, one test asserting the wrong user gets a 404. These are the cheapest security tests you will ever write and they catch regressions in the category that ranks first.

Where AI genuinely helps — and its two failure modes

AI coding assistants are now part of how most teams write software, so the question is not whether to use them but where they help and where they need a guardrail.

Where they help. Explaining an unfamiliar vulnerability class in the context of the file you are actually editing, which is far more effective than abstract training. Drafting the abuse-case tests from habit 5, which developers skip because writing them is dull. Translating a finding from a penetration test report into a patch against your own code. Migrating a pattern repetitively and correctly — replacing every raw query in a codebase with a parameterised call is exactly the kind of large, boring, mechanical change models are good at. And reviewing a diff for the well-defined classes: a missing tenant scope, a fast password hash, an unpinned JWT algorithm.

Failure mode one: assistants reproduce the patterns they were trained on, and a great deal of public code is insecure. Ask for a file upload handler and you may well get one with no type validation and a path built by concatenation, because that is the median example on the internet. Generated code deserves more review than hand-written code in the classes above, not less — it arrives fluent, complete and confident, which are the qualities that discourage scrutiny.

Failure mode two: AI review produces plausible false positives. We documented this while building our own agent-driven testing workflow: recall rises sharply, precision collapses. A model writes a well-argued, correctly formatted description of a vulnerability that is not there, and the only way to dismiss a coherent argument is to investigate it — so triage costs what a real finding costs. In a code review context that shows up as a queue of confident comments that developers learn to ignore, which is worse than no tool at all.

The rule that keeps AI useful here. Let it propose, never let it approve. An AI comment is a hypothesis for a human to confirm — and a finding it cannot demonstrate with a concrete input and observable outcome should not block a merge. The moment developers stop believing the tool, you have lost more than you gained.

One more thing that is easy to miss: if your assistant sends code to a third-party model, that is source code leaving your environment, and your proprietary logic and any secrets embedded in it go with it. That belongs in a policy, not in individual judgement.

Making it stick: gate a little, warn a lot

The fastest way to kill a secure-coding programme is a pipeline that fails builds on low-confidence findings. Gate only on things that are unambiguous and cheap to fix; report everything else without blocking.

CheckTreatmentReasoning
Secret detectionBlockNear-zero false positives, and the cost of merging one is rotation plus incident handling
Dependencies with known exploited CVEsBlockUnambiguous and actionable — there is a version to move to
SAST, high confidence rules onlyBlock on new codeGate the diff, not the backlog, or the first run blocks everything and gets disabled
SAST, everything elseWarnUseful signal, too noisy to be a gate
AI review commentsWarn, human adjudicatesPlausible false positives; blocking on them destroys trust in the tool
Missing abuse-case test on a new endpointBlock, once the habit existsCheap to satisfy and directly targets the top-ranked category

Pair that with a threat-modelling conversation at design time for anything touching money, identity or personal data — half an hour with a whiteboard prevents the class of flaw no scanner finds. Our threat modelling guide covers how to run one without it becoming a ceremony, and the application security testing guide explains where SAST, DAST, IAST and SCA each fit.

How Adayptus helps teams build this

Most secure-coding engagements fail because they deliver a training session and a PDF. What changes behaviour is working on the team's own code, in the team's own pipeline, with findings a developer can act on the same day.

Practically, that means we review your code and tell you the pattern behind each finding rather than just the line — one missing tenant scope in the invoice repository is more useful than fourteen individual IDOR tickets. Every finding we deliver is reproduced by hand before it reaches you, which is why we commit to zero false positives in delivered findings: a developer who has been sent three imaginary bugs stops reading the fourth report. Where a finding is a symptom of a missing guardrail, we say so and help you build the guardrail — the scoped repository, the lint rule, the CI gate — so the class stops recurring rather than the instance getting patched. And remediation retesting is included at no additional cost.

What you needService
Someone to read the code and find the patternsSecure Code Review
Design-time review before it is built wrongThreat Modeling
The running application tested, logic includedWeb App Pentesting · API Pentesting
Security checks wired into CI/CD properlyDevSecOps · CI/CD Security Review
A measured view of AppSec maturity and what to fix firstAppSec Maturity Assessment
Your AI features tested, not just built with AIAI Security Assessment

Frequently Asked Questions

Click any question to expand the answer.

QWhat is the single highest-value secure coding habit?

Making the caller's identity part of every data access, so an unscoped read is not expressible in your codebase. Broken Access Control is A01 in OWASP Top 10:2025 and OWASP found it in 100% of applications tested. Binding tenant or owner scope inside the data layer, rather than checking it in each handler, removes the whole class rather than individual instances.

QDoes using an ORM protect against SQL injection?

For normal query-builder use, yes — values are sent as parameters. It does not protect you in the raw escape hatches such as queryRaw, .extra() or literal(), which are string concatenation with better branding and still need parameters. It also cannot help with identifiers: table and column names, including a sort column from a query string, cannot be parameterised and must be validated against an allow-list.

QIs AI-generated code less secure than hand-written code?

It reproduces the patterns it was trained on, and a great deal of public code is insecure — so a generated file upload handler or database helper may well arrive with the median internet mistake built in. The practical risk is that generated code looks fluent and complete, which discourages scrutiny. Treat it as needing more review in the access-control, crypto and injection classes, not less.

QShould a failed security check block the build?

Block on checks that are unambiguous and cheap to fix: committed secrets, and dependencies with known exploited vulnerabilities. Gate high-confidence SAST rules on new code only, so the first run does not block on the entire backlog. Everything else, including AI review comments, should warn rather than block — a pipeline that fails on low-confidence findings gets disabled, and then you have no checks at all.

QA secret was committed and then removed. Is that fine?

No. Deleting the line does not remove the object from git history, and if the repository was ever pushed you should assume the value is compromised. Rotate the credential, then clean history if you need to. The durable fix is a pre-commit secret scanner plus a rotation runbook, so this becomes a five-minute event rather than an incident.

QHow does Adayptus help a team build secure coding practices?

We work on your code and in your pipeline rather than delivering a training deck. Findings are reported as patterns, not just lines — one missing tenant scope beats fourteen separate IDOR tickets — and where a finding reveals a missing guardrail we help build it, whether that is a scoped repository, a lint rule or a CI gate. Every delivered finding is reproduced by hand, which is why we commit to zero false positives, and remediation retesting is included at no additional cost.

References

Code samples are illustrative and deliberately minimal. They show the shape of each fix rather than production-ready implementations — error handling, observability and framework specifics will differ in your codebase.


Share this Insight
CybersecurityApplication SecurityAdayptus Intelligence
Peyush Baranwal

Peyush Baranwal

Senior Delivery Manager - Cyber Security, Adayptus

Peyush Baranwal is a Senior Delivery Manager at Adayptus Consulting with 11+ years of experience designing, implementing, and managing enterprise security programmes. His core expertise spans Vulnerability Assessment & Penetration Testing (VAPT), Application Security, and Security Operations — leading web, mobile, API, and infrastructure security assessments for CISOs and security teams across BFSI, healthcare, and SaaS. He focuses on measurable risk reduction, governance maturity, and operationalising detection-and-response capability. Outside work, Peyush is a passionate biker and part-time photographer.

Connect on LinkedIn
Application Security

Fix the Pattern, Not the Line

We review your code and report the pattern behind each finding, then help you build the guardrail so the class stops recurring. Send us a repository and your stack, and we will come back with scope and an indicative quote — usually within one business day.

  • Findings reported as patterns, not a list of lines
  • Every delivered finding reproduced by hand — zero false positives
  • Guardrails built with your team: scoped repositories, lint rules, CI gates
  • Free retest once you have remediated
Direct Scoping Hotline: +91-9625999069 [email protected]

Request a scoping call

No obligation. A senior consultant replies — not a sales sequence.

Your details stay confidential. Covered by NDA — a senior consultant replies directly.

Zero False Positives Free Retest Included 100% NDA Protected