What is SQL injection?
SQL injection (SQLi) is an attack that inserts attacker-controlled SQL into a query your application then executes. It happens when you build the statement by concatenating strings, so user input can change the query's structure rather than sit in a single bound value.
A successful injection can read rows the caller should not see, change or delete data, and in severe cases run database commands that take over the host. Login forms, search boxes, and signup handlers are the usual entry points because they take strings and immediately query the database.
The fix is structural: never let untrusted input become SQL syntax. Use parameterized queries (prepared statements) so the driver sends the statement and the values separately. Input validation and least-privilege database roles reduce the blast radius. They do not replace parameterization.
What is cross-site scripting (XSS)?
Cross-site scripting (XSS) is an attack that injects script into a page other users then load. The browser trusts the page because it came from your origin, so the script can read cookies, steal session tokens, rewrite the DOM into a phishing form, or pull in malware.
Reflected XSS echoes the payload in the immediate response (a search result, an error message). Stored XSS persists the payload (a comment, a profile field) and runs for every later viewer. DOM XSS writes the payload into the page from client-side JavaScript without going through your server template.
The fix is to treat every untrusted string as data when you render it. Escape HTML on output, use a templating engine that encodes by default, and add a Content Security Policy (CSP) so a missed escape is less likely to execute. React encodes text children by default. dangerouslySetInnerHTML and server-rendered HTML strings do not.
How do these show up in Node.js?
In Node.js the vulnerable pattern is the same as in any other language: a string built from req.body or req.query is passed to the database or written into an HTML response.
A login handler that concatenates SQL looks like this:
// Vulnerable: input becomes SQL syntax.const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;db.get(query, onResult);A comment handler that echoes the body looks like this:
// Vulnerable: the browser parses the comment as HTML.res.send(`Received comment: ${comment}`);You do not need a large demo app to see the failure. If username is admin'; --, the rest of the predicate is commented out. If comment is a <script> tag, the next viewer runs it. Trim the payload games: the defect is the concatenation and the unescaped write, not a clever string.
These bugs stay in the OWASP Top 10 because they are easy to introduce in a hurry and expensive when they ship. A scanner can find some of them. A runtime check in the request path can deny the obvious attack shapes even when a new handler forgets to parameterize. For the definition of that layer, see what is runtime application security.
How do you prevent SQL injection in Node.js?
Use parameterized queries for every statement that includes untrusted values. The driver sends the SQL with placeholders and binds the values at execution time. The database never parses those values as syntax.
The following example also stores a hash rather than the password, because the two habits get taught together and the naive version of this handler gets copied. Look the account up by username, then verify the supplied password against the stored hash with a slow algorithm such as argon2id or scrypt. A password never belongs in a WHERE clause: that turns verification into a constant-time-unsafe string comparison the database performs, against a column that should never have held the plaintext in the first place. Secure your login pages covers the rest of the credential path.
import argon2 from "argon2";import { Router } from "express";import { db } from "./database";
export const securedRoutes = Router();
securedRoutes.post("/login", (req, res) => { const { username, password } = req.body; // Look the user up by username only. Never put a password, or a hash of // one, in a WHERE clause: verification is a slow-hash comparison. db.get( "SELECT id, username, password_hash FROM users WHERE username = ?", [username], async (err, user) => { if (err) { res.status(500).send("Error"); return; } if (!user || !(await argon2.verify(user.password_hash, password))) { res.status(401).send("Invalid credentials"); return; } res.send("Signed in"); }, );});
securedRoutes.post("/signup", async (req, res) => { const { username, password } = req.body; const passwordHash = await argon2.hash(password); db.run( "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", [username, passwordHash, "user"], (err) => { if (err) { res.status(500).send("Error"); return; } res.redirect("/"); }, );});ORMs and query builders (Prisma, Kysely, Drizzle, Knex) parameterize when you use their APIs. $queryRawUnsafe and string-built knex.raw are the same defect with a nicer name. If you must use a raw string, pass bindings as a separate argument.
Give the application database role only the statements it needs. A signup handler that can UPDATE a role column is one injection away from a self-made admin, even with placeholders, if the statement itself writes that column from user input. Keep role out of the bind list and out of the granted columns.
How do you prevent XSS in Node.js?
Escape on output. Encode <, >, &, and quotes so the browser treats them as text.
import escapeHtml from "escape-html";
securedRoutes.post("/comment", (req, res) => { const { comment } = req.body; res.send(`Received comment: ${escapeHtml(comment)}`);});Prefer a template engine or UI library that escapes by default (React text nodes, tagged template libraries with an escape rule). Audit every path that sets innerHTML, dangerouslySetInnerHTML, or a raw res.send of interpolated HTML.
Add a Content Security Policy so a missed escape has less to execute:
app.use((_req, res, next) => { res.setHeader( "Content-Security-Policy", "default-src 'self'; script-src 'self'", ); next();});Helmet sets CSP and the rest of the common security headers for Express. CSP is a backstop. It does not make unescaped HTML safe.
Validate input as well. A username that must be alphanumeric never needs a quote in the first place. Use a library such as Zod or express-validator on the server. Client-side checks are for the form, not for the API.
What Node.js version should you run?
Use Node.js 24, the Active LTS line. Node.js 22 is in Maintenance LTS and still gets security fixes until April 2027, so it is a fine place to sit while you plan the upgrade. Node.js 20 reached end of life on 30 April 2026 and no longer receives them, which matters because it was one of the most widely deployed LTS lines; if you are on 20 or below, upgrade before you spend time on anything else in this guide. @arcjet/node declares >=22.21.0 <23 || >=24.5.0, so Node.js 23 is not a supported target.
Pair that with typescript in strict mode and current @types/node so the compiler catches the obvious mistakes before a request does.
How does Arcjet Shield add a runtime layer?
Parameterized queries and output escaping are the correct fixes. They still fail when a new route concatenates a string, when a dependency is vulnerable, or when an attacker enumerates forms looking for the one handler you have not hardened.
Arcjet Shield is a WAF that runs in your Node.js process. It inspects the request for SQL injection, XSS, and other common attack shapes, and it returns a decision you act on before the query or the HTML write. That is the same placement described in how to detect and block attacks at runtime. It complements, and does not replace, parameterization and escaping.
import arcjet, { shield } from "@arcjet/node";import express from "express";
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [shield({ mode: "LIVE" })],});
const app = express();app.use(express.urlencoded({ extended: true }));
app.post("/login", async (req, res) => { const decision = await aj.protect(req); if (decision.isDenied()) { res.status(403).send("Forbidden"); return; }
// Parameterized query here.});Start with shield({ mode: "DRY_RUN" }) on production traffic if you want to measure what would have been denied, then switch that rule to LIVE. Add detectBot and a fixedWindow or slidingWindow on login and signup so a scanner cannot walk the form at full speed. For the WAF-versus-SDK trade-off on a Next.js host, see does Next.js need a WAF?. The same Shield rule is the Node.js answer.
Shield is not a license to concatenate SQL. A denied request is a request that never reached the bug. A parameterized query is a bug that is not there.
What is the baseline for a Node.js app?
Define the two bugs plainly: SQL injection is untrusted input that becomes SQL syntax, and XSS is untrusted input that becomes script in someone else's browser. Prevent the first with parameterized queries and a least-privilege database role. Prevent the second with output escaping, default-safe templates, and a CSP. Validate on the server. Run Node.js 22 LTS. Then put Shield on the handler so the request path can still deny an injection attempt when a new route is sloppy. That combination is code hygiene plus runtime application security.
Frequently asked questions
What is SQL injection?
An attack that inserts attacker-controlled SQL into a query your app executes, usually because you concatenated req.body or req.query into the statement. Parameterized queries keep values from becoming syntax.
What is cross-site scripting (XSS)?
An attack that injects script into a page other users load. Escape HTML on output, use default-safe templates, and add a Content Security Policy.
Is Shield a substitute for parameterized queries?
No. Shield denies common attack shapes in the request path when a handler is sloppy or a dependency is vulnerable. Parameterization removes the bug. Use both.
Which Node.js version should you use?
Node.js 22 LTS. Node.js 18 is end of life and no longer receives security fixes.
Does React make XSS impossible?
React encodes text children. dangerouslySetInnerHTML, server-rendered HTML strings, and unescaped res.send interpolations still execute script.
Application security in your code
Protect your application with Arcjet
Get rate limits, bot detection, and attack blocking in your request handlers.