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.
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.
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.
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.
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.
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.
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.
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
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.
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)// GOOD: Check authentication on every API route
export async function GET(request: Request) {
const user = await getAuthUser(request)
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Only return this user's data
const tasks = await db.select().from(tasks).where(eq(tasks.userId, user.id))
return NextResponse.json(tasks)
}
// GOOD: Use middleware to protect all /api routes (middleware.ts)
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith("/api")) {
const token = request.headers.get("Authorization")
if (!token) return new Response("Unauthorized", { status: 401 })
}
}Per-language tracing and limits
| Server | What 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. |
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.