SAST scanner · LSP validation

Route Auth Tracing

Every API route is asked two questions: does anything check who is calling, and does anything validate what they sent? The cheap answer comes from the mount line. The real answer comes from reading the handler, and from a language server following the guard to its definition to see whether it refuses anyone at all.

SASTMedium to CriticalAuth weaknessPhase 7 → 7.5route_auth + AuthFlowTracer

Mapping routes, per framework

A route is a file or a line, depending on the framework, so each framework has its own mapper. Next.js App Router routes are files named route.ts under app/api/; Express routes are app.get() and router.post() calls; tRPC routes are procedures. The mappers turn all three into the same shape, and the analyzer asks the same two questions of each.

package.json framework detection next express @trpc/server NextJSRouteMapper ExpressRouteMapper TRPCRouteMapper Next.js · app/api/**/route.ts app/api/tasks/route.ts export async function GET(request) {…} export async function POST(request) {…} Express · app.get() / router.post() app.get('/orders', requireAuth, list) router.post('/orders', create) kept only if reachable through the import graph tRPC · procedures list: protectedProcedure.query(…) create: publicProcedure.mutation(…) protectedProcedure vs publicProcedure Two checks per route route · auth call · input validation GET /api/tasks auth none · validation none POST /api/tasks auth none · raw request.json() GET /orders auth requireAuth · validation zod POST /orders auth none · validation zod trpc.create auth publicProcedure red = a finding, pending the checks below

1Repo ingestion reads package.json to detect the framework, and a framework-specific mapper is chosen. The analyzers never parse framework syntax themselves.

2Next.js App Router. Every app/api/**/route.ts is a route; its exported GET, POST, PUT, PATCH and DELETE functions are its methods.

3Express. Mount lines such as app.get('/path', …) and router.post() are found everywhere, then filtered to the ones reachable through the import graph, so unused fixture routes are ignored.

4tRPC. Each procedure is a route. Whether it is built on protectedProcedure or publicProcedure is the first auth signal.

5For every route: is there an auth call (getUser, getSession, auth(), verifyToken, requireAuth…) and is incoming data validated before use, or is request.json() / searchParams.get() consumed raw? A "none" is a candidate finding that the next three sections try to disprove.

Recognising a guard on the mount line

For Express, the mapper answers has_auth_check from the mount line alone, and a True suppresses the route's missing-auth finding outright. So the match is deliberately narrow: known guard names, on word boundaries, against the route's arguments only, with comments stripped. Each bound exists because it was once missing.

mount line under test Known guard names AUTH_MIDDLEWARE_INDICATORS requireAuth · verifyAuth authenticate · isAuthenticated jwt.verify · verifyToken checkAuth · ensureAuth · … 14 names, word-boundary only app.get('/api/users', checkAuth, listUsers) '/api/users'path · never matched checkAuth\bcheckAuth\b matches listUsershandler · no match has_auth_check = Truea guard is applied to this route; the tracer will later read what checkAuth actually does app.get('/api/authors', checkAuthorEmail, listAuthors) '/api/authors'path · never matched checkAuthorEmail\bcheckAuth\b does not match listAuthorshandler · no match Before: True (bug)substring "checkAuth" found inside the name; route cleared Now: Falseword boundary: checkAuthorEmail is not checkAuth app.get('/api/users', authenticatedUsers) '/api/users'path · never matched authenticatedUsers\bauthenticate\b does not match this IS the handler:it returns Juice Shop's user list Before: True (bug)"authenticate" inside the handler's name cleared it Now: Falsethe user-list route is reported as unguarded app.get('/api/authenticate', handler) '/api/authenticate'path · excluded from the match handlerno match trailing commentsstripped before matching Before: True (bug)whole-line scan: "guarded by its own URL" Now: Falsearguments only; the path is not evidence router.get('/orders', isLoggedIn, (req, res) => {…}) '/orders'path · never matched isLoggedInyour word; not on any list (req, res) => {…}anonymous handler has_auth_check = False · absence of evidence, not evidence of absencethe route is still examined: its own handler body next, then the LSP tracer resolves isLoggedIn and reads it Comments are stripped first. "guarded by" was once read off a trailing // vuln-code-snippet comment. The list is the cheap answer for scans with no language server. What a guard does is decided by the tracer, which reads its body.

1The mount line is split into arguments. The path is set aside, and each remaining argument is tested against the known guard names with \bname\b. checkAuth is on the list, so has_auth_check is True and the missing-auth finding is suppressed.

2Past bug one. A substring search found checkAuth inside checkAuthorEmail and cleared the route. Word-boundary matching sees a different identifier and answers False.

3Past bug two. authenticate was found inside authenticatedUsers, the handler that returns Juice Shop's user list, so the one route that most needed a guard was marked as having one.

4Past bug three. Scanning the whole line meant app.get('/api/authenticate', handler) was guarded by its own URL. Only arguments are matched now, and trailing comments are stripped first.

5A False is only an absence of evidence. The list cannot know a project's own vocabulary such as isLoggedIn or ensureMember, so the route goes on to the handler scan and the tracer either way.

Auth checked inside the route's own handler

Many routes carry no named guard and check auth in the body instead. An anonymous function has no name to recognise, so before a finding is raised the route's own handler is read. Two things must both be present: a lookup of who is calling, and a refusal when the answer is nobody.

routes/orders.js router.get('/orders', (req, res) => { const session = getServerSession(req) if (!session) return res.status(401).end() … LOOKUP REFUSAL 401 Authenticated: lookup AND refusal the missing-auth finding for GET /orders is not raised routes/users.js router.get('/users/:id', (req, res) => { const user = getUser(req.params.id) res.json(user) }) LOOKUP NO REFUSAL Not counted: an id from the caller, nobody refused this is what an IDOR looks like; the finding stays Scope: the route's own handler, never the whole file one server.ts shares its content with a hundred routes; searching the file would let a single guarded neighbour clear all of them

1The Express mapper answers "no guard" for this route: the handler is an anonymous function and nothing on the mount line is a known name. That is not "no auth", so the handler body is read next.

2Lookup. getServerSession(req) identifies the caller. On its own this proves nothing yet.

3Refusal. return res.status(401) turns an anonymous caller away. Lookup and refusal together mean the handler authenticates, and the finding is not raised.

4The counterexample. getUser(req.params.id) is a lookup too, but the id came from the caller and res.json(user) refuses nobody. Accepting a lookup alone would suppress the finding on a route whose whole problem is that it never asks who is calling. This must not count.

5Only the route's own handler is read. A file like server.ts holds a hundred routes; reading the file would let one guarded route vouch for ninety-nine others.

The LSP auth-flow tracer

Phase 7.5 runs when a language server is available (tsserver, pyright, jdtls, gopls). The AuthFlowTracer takes each guard the route applies, asks the server for its definition, and reads that function's own body for an auth terminal. A finding it disproves is removed. No pattern list can name getUserFromRequest, a helper your project invented; the tracer can, because it reads what the name resolves to.

app/api/tasks/[id]/route.ts import { protectedProcedure } from "@/lib/trpc" get: protectedProcedure.query(…) MISSING AUTH? VERIFIED go-to-def lib/trpc.ts 42export const protectedProcedure = 43 t.procedure.use(async ({ next }) => { 44 const { data } = await supabase.auth.getUser() 45 if (!data.user) throw UNAUTHORIZED AUTH TERMINAL Suppressed auth IS applied: the guard resolves to a real identity check removed from the report, for this route only server.ts · a hundred mounts, each answered from its own line app.use('/api/BasketItems', security.isAuthorized())traced → verified · suppressed app.post('/api/Challenges', security.denyAll())traced → verified · suppressed app.get('/api/Products', …)no guard → not verified · reported … 97 more, each with its own verdict, per methodvouching is the dangerous direction a finding carries its route, so one guarded route in a file of a hundred cannot vouch for the rest

1The route file has no inline getUser call. The regex pass sees only an imported name, protectedProcedure, and raises a candidate missing-auth finding.

2The tracer asks the language server for the definition of protectedProcedure. Go-to-definition lands on trpc.ts:42. Only that function's own body is read, never the whole module, so a login route importing signToken from a file that also defines verifyToken cannot look authenticated.

3Inside it: supabase.auth.getUser() and a refusal on the failing branch. That is an auth terminal, which is what the tracer was looking for.

4Auth is genuinely applied, so the finding is a false positive and is suppressed. No commercial SAST tool uses compiler-level definition resolution to check that auth middleware actually works.

5Suppression is matched back per route, per method. In a server.ts that mounts a hundred routes, the two guarded mounts are cleared and /api/Products is still reported. One verdict for the file would either miss every guard or vouch for every unguarded neighbour, and a suppressed finding is a vulnerability the report no longer mentions.

What else the TypeScript tracer follows

Router-wide middleware found by where it is applied (a path-less router.use(ensureMember) guards every route below it, whatever its name). Middleware reached through a local alias (const isLoggedIn = sessionHandler.isLoggedInMiddleware) is followed to what it names, up to MAX_TRACE_DEPTH hops; when tsserver returns no definition (untyped CommonJS, this.x = … in a constructor) the route stays unverified and reported, never falsely cleared. A guard that only rejects (else res.status(403)) counts, but only when it is read from the arguments before the handler, since every handler has an error path of its own. Library middleware (expressJwt, requiresAuth, ensureLoggedIn) is a terminal because tracing never enters node_modules. Generic lookups (getUser(, getSession(, getToken(, authenticate() count only next to a refusal, bcrypt.compare never counts, comments are ignored, and in files without mounts an auth decorator, tRPC procedure base or inline call vouches only for the route it belongs to.

Routes it deliberately stays quiet about

Some routes are meant to be open, and flagging them is noise. The exemption list is short, about what a route is, and matched exactly. Webhook receivers are not on it: they authenticate too, by verifying the sender's signature, and that is what gets recognised.

Exempt · exact match only /health /ping /status /ready /livez /readyz /docs /swagger /openapi / (root) names standardised outside your project; no app words webhooks: NOT on the list GET routes: NOT on the list Routes under review GET /healthexempt GET /api/health-recordsexamined patient data; a fragment match cannot tell POST /api/webhooks/stripeby signature POST /api/webhooks/testreported it SENDS webhooks: an SSRF sink, no receiver A receiver proves itself signature verification, wherever it lives req.headers['stripe-signature'] stripe.webhooks.constructEvent(…) crypto.timingSafeEqual(a, b) also: x-hub-signature (GitHub, Meta) svix-signature (Svix, Clerk) verified → counts as authenticated unverified → reported Not finding a guard is never itself grounds for staying quiet a rule once exempted any GET route the mapper reported no auth on: the exact condition a missing-auth finding exists to report. the Express mapper answers True or False and never "unknown", so no Express GET route could produce a missing-auth finding at all.

1The list: health and liveness probes, API documentation, and the root path. Every name is standardised outside your project. App-specific names are kept out on purpose, because a scanned app that reused one would have a real finding silently suppressed.

2Matching is exact. /health is a liveness probe; /api/health-records is patient data. A fragment match cannot tell them apart, so the second is examined like any other route. A public-by-design route of your own is suppressed by fingerprint (--suppress), which is visible and reversible.

3Webhook receivers authenticate by signature, the sender having no session to present. So they are recognised directly: constructEvent, timingSafeEqual, or a read of stripe-signature / x-hub-signature / svix-signature. A receiver that checks counts as authenticated wherever it lives.

4Exempting by path was the right idea checked the wrong way. A path is not evidence: an endpoint with "webhook" in its name may be one that sends them, an SSRF sink. The test app's /api/webhooks/test is exactly that, and it is reported.

5The same principle killed one more rule. Exempting "any GET route with no guard found" read the condition backwards, and because the Express mapper always answers True or False, no Express GET route could ever produce a missing-auth finding. Silence has to be earned by what the route is or does.

Why it matters

One route is enough

A single API route without authentication is often a full data breach. Unauthenticated reads expose all data to anyone; unauthenticated writes let attackers create, modify or delete records; an admin endpoint without auth lets any user perform admin actions. It is especially common in vibe-coded apps where routes are added quickly, or where auth went on the pages and not on the API routes behind them.

Real-world breaches

Optus Australia (2022): an unauthenticated API endpoint exposed personal records of 9.8 million customers, including passport numbers and driver's licences. No API key or auth token was required.

Peloton (2021): API endpoints lacked authentication, letting anyone query account data (age, gender, city, weight) for any Peloton user without logging in.

Vulnerable vs fixed

// BAD: No auth check, anyone can list all users
export async function GET() {
  const users = await db.select().from(profiles)
  return NextResponse.json(users)
}

// BAD: Auth on the page but not the API route
// src/app/dashboard/page.tsx  → has auth check
// src/app/api/tasks/route.ts  → no auth check (accessible directly)

Per-language tracing and limits

ServerWhat the tracer follows
TypeScript (tsserver)tRPC protectedProcedure → middleware → getUser(); Express chains requireAuth → verifyToken → jwt.verify(); Next.js inline helpers such as getUserFromRequest → lib/auth.ts → verifyToken(); path-less router.use(); local aliases; centrally mounted Express routes, per route and per method.
Python (pyright, or pylsp)FastAPI Depends(get_current_user) → verify_token → jwt.decode(); Django @login_required → django.contrib.auth → session check.
Java / Kotlin (jdtls, kotlin-language-server)@PreAuthorize("hasRole('ADMIN')") → SecurityConfig → role hierarchy; SecurityFilterChain → request matcher → role check.
Go (gopls)Group middleware (api.Use(AuthMiddleware())) guards every route on it; an inline handler that identifies the caller and refuses; a helper in another file (mustAuth in mw.go) followed by go-to-definition.
When the tracer does not run

Phase 7.5 needs a usable language server. The TypeScript server carries no TypeScript of its own and ingestion strips node_modules, so Phase 6.5 locates a TypeScript 5.x tsserver.js ($ISITSECURE_TSSERVER_PATH, the scanned project, ~/.isitsecure/lsp, then the global npm root). Without one the phase is skipped entirely, and the route analyzer's own verdicts stand: the mount-line match and the handler scan, with nothing disproved by resolution. A route the tracer cannot resolve stays unverified and reported; it is never cleared by a failed lookup.