Security Basics

9 min

Semgrep Security Rules for JavaScript That Matter

Semgrep Security Rules for JavaScript That Matter

An app can pass every happy-path test and still ship a service-role key in source or an IDOR route. Where Semgrep actually helps in a JavaScript codebase, and what a clean scan still can't prove.

An app can pass every happy-path test and still ship a service-role key in source or an IDOR route. Where Semgrep actually helps in a JavaScript codebase, and what a clean scan still can't prove.

A JavaScript application can look finished, pass its happy-path tests and still contain a route that lets one customer read another customer's records. It can ship with a service-role key in a server file, accept an unverified JWT, or trust an ID supplied by the browser. Semgrep security rules for JavaScript help find these repeatable code patterns before they become account compromise, fraud or a public data exposure.

For founders moving quickly with AI-assisted development, Semgrep is useful because it reads source code structurally rather than merely searching for words. A good rule can identify a dangerous function call, trace how untrusted input reaches it and flag the exact file and line that needs attention. That makes it a practical first pass for a growing repository - but it is not a substitute for checking whether the application actually enforces access controls in production.

What Semgrep catches well in JavaScript

Semgrep is a static analysis tool. It scans code without running the application and compares it against rules describing insecure patterns. For JavaScript, that can include browser code, Node.js APIs, serverless functions and Next.js route handlers. The result is most valuable when rules reflect the way your product handles identity, data and money.

The obvious use is secret detection. A hard-coded API key, Supabase service-role key, private token or payment-provider secret may be found in source files, test fixtures and forgotten scripts. Even when the repository is private, a secret in code is a deployment and access-control problem: it can reach build logs, previews, contractors or a compromised developer account.

Semgrep also works well for dangerous data handling. Consider an API route that passes req.query.id directly into a database query, or an admin endpoint that checks only whether a user is signed in. Neither issue necessarily produces an error. The feature works, which is precisely why these flaws can survive until a real user tries a different identifier.

Rules can also identify risky primitives that deserve review, such as eval, child_process.exec, unsafe deserialisation, dynamically constructed regular expressions and HTML rendering without sanitisation. A finding is not automatically a vulnerability. exec may be safe if its command is fixed and inputs are tightly controlled. The point is to focus review on the code where a small assumption can create a large incident.

Semgrep security rules for JavaScript: start with business risk

A large community ruleset can produce hundreds of findings in a busy JavaScript repository. That is useful only if someone can separate an exposed credential from a style preference. Start with rules that map to the ways a live SaaS application can be harmed.

Prioritise rules for these four areas:

  • hard-coded credentials and use of privileged service keys in client-accessible code;

  • authentication checks that decode a JWT without verifying its signature, issuer or expiry;

  • authorisation gaps where a route loads, updates or deletes a record using a request-controlled ID without checking ownership or role;

  • injection and unsafe output paths, including shell execution, SQL construction and unsanitised HTML.

The first two are often straightforward to detect. The third requires more care. An IDOR flaw is not simply the presence of req.params.id; most legitimate routes use a record ID. The concern is whether the query also constrains the record to the authenticated user, tenant or approved organisation.

For example, this route deserves investigation:

const invoice = await db.invoice.findUnique({
  where: { id: req.params.invoiceId }
});
return res.json(invoice);
const invoice = await db.invoice.findUnique({
  where: { id: req.params.invoiceId }
});
return res.json(invoice);
const invoice = await db.invoice.findUnique({
  where: { id: req.params.invoiceId }
});
return res.json(invoice);

The code may be acceptable behind an internal administrator-only gateway. In a customer-facing route, it is likely missing an ownership check. A safer query ties the requested record to the session identity or tenancy boundary:

const invoice = await db.invoice.findFirst({
  where: {
    id: req.params.invoiceId,
    accountId: session.accountId
  }
});
const invoice = await db.invoice.findFirst({
  where: {
    id: req.params.invoiceId,
    accountId: session.accountId
  }
});
const invoice = await db.invoice.findFirst({
  where: {
    id: req.params.invoiceId,
    accountId: session.accountId
  }
});

Even then, verify the source of session.accountId and the database policy behind it. If the application uses Supabase, row-level security should enforce the same boundary. Application code can make the right query today and regress in a future feature. RLS provides a second control at the data layer.

Write rules around your framework, not generic examples

A generic rule for res.json() will create noise. A better rule understands the framework conventions in your repository. Next.js applications may use request.nextUrl.searchParams, Express applications commonly use req.params and req.body, while serverless handlers may receive an event object. AI-generated code frequently mixes patterns from several frameworks, so do not assume consistent conventions.

Custom rules should target the risky sink and the paths that lead to it. For instance, flag privileged Supabase client creation when the service-role key is sourced from a variable that could be bundled into browser code. Flag jwt.decode() in an authentication middleware file when the decoded result is used as an identity assertion. Flag database writes in sensitive route directories when no recognised authorisation helper appears nearby.

That last example illustrates a trade-off. Semgrep is excellent at finding suspicious patterns, but codebases vary. A strict rule may catch real bugs and create false positives where authorisation occurs in middleware. A loose rule misses cases. Tune rules against your own routes, then use severity levels that reflect the likely impact: critical for directly exposed service credentials, high for an unauthorised data path, and lower severity for a risky helper that needs context.

Do not mistake a clean scan for a secure application

Static analysis sees code, not the whole running system. It cannot reliably tell whether a Supabase table has RLS disabled, whether a preview deployment exposes debug endpoints, whether CORS allows an untrusted origin, or whether a payment webhook verifies the provider signature in production. It also cannot prove that every user flow applies the intended tenant boundary.

This matters because JavaScript applications increasingly split security decisions across frontend code, API handlers, managed authentication, database policies and third-party services. A Semgrep finding may show that a route trusts client input. Manual review determines whether the route is reachable, which role can call it, what data it exposes and whether a database policy blocks the action anyway.

The reverse is true too. A clean Semgrep scan can coexist with a severe configuration mistake. A service-role key may be kept in an environment variable, which avoids a source-code secret finding, yet be used by a publicly reachable endpoint. The code looks tidy while the design bypasses RLS for any caller who can reach that endpoint.

Make findings actionable for a small team

Run Semgrep early - before a feature branch is merged - and again when preparing a launch. For a small team, the best workflow is not to block every pull request on every warning. Begin by failing builds for critical secret and authentication findings, while sending lower-confidence rules to review. Otherwise developers learn to ignore security output because it is constantly noisy.

Each finding should answer four operational questions: what file contains the issue, how an attacker could reach it, what they could gain, and the exact change required. “Potential injection” is not enough. “api/admin/export.js passes a request value into exec; an authenticated user could run an arbitrary command on the worker. Replace shell execution with a fixed command and validated argument list” gives a team somewhere to start.

Keep an exceptions file for accepted findings, but make every exception deliberate. Record why it is safe, who approved it and when it should be reviewed. A temporary exception around a migration script can be reasonable. An exception for a client-side service-role key is a release blocker, not technical debt.

HollowByte uses Semgrep alongside credential scanning, dependency checks and manual application testing because each method catches a different failure mode. The useful outcome is not a long tool report. It is a short, severity-rated set of fixes that protects customer data and lets the team keep shipping.

If your app has routes that read customer records, write to a database, process payments or call privileged services, treat those paths as release-critical. Put focused Semgrep rules around them, verify the live controls those rules cannot see, and fix the finding while the engineer who built the feature still has the context.

Back to blog

(function() { function applyMainRole() { var hero = document.getElementById('hero'); if (!hero) return false; var node = hero; while (node.parentElement && node.parentElement !== document.body) { node = node.parentElement; } if (node && node.parentElement === document.body) { node.setAttribute('role', 'main'); return true; } return false; } if (applyMainRole()) return; var attempts = 0; var interval = setInterval(function() { attempts++; if (applyMainRole() || attempts > 20) { clearInterval(interval); } }, 250); })();