audit-auth
Find pracht routes that look protected but aren't: missing auth middleware, middleware that augments context but never gates, client-only checks, and unguarded API mutations. Use for "audit auth", "check route protection", "find unauthenticated routes", "review middleware coverag
Install
npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/audit-auth
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
git clone https://github.com/JoviDeCroock/pracht.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jovidecroock/pracht collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Pracht Audit Auth
What the framework guarantees: middleware runs wrap-around style — every
middleware must return a Response (the runtime throws if it doesn't), either
return next() to continue the chain or a short-circuit Response to stop it.
The redirect() helper from @pracht/core returns a scheme/CRLF-validated
redirect Response. What the framework does NOT decide is which routes get
an auth gate — that is app wiring, and this skill audits it.
The pracht auth pattern (see https://pracht.resynapse.dev/docs/recipes/auth):
middleware loads the session onto context.session and short-circuits with a
redirect when there is no user; loaders downstream read context.session.
@pracht/session is the first-party implementation. Two of its exports map
straight onto the Gate/Augmenter classification below — requireSession() is
a Gate, sessionMiddleware() is an Augmenter — so a project using it can be
classified from the factory name without reading the middleware body. A
project that hand-rolls its session instead is not automatically wrong, but
check it for the things the package handles: an expiry inside the signed
payload (not only Max-Age), a constant-time or crypto.subtle.verify
signature check, HttpOnly/Secure/SameSite, and encryption if the cookie
carries anything beyond an opaque id.
Prerequisites: pracht inspect requires a vite config that registers the
pracht plugin.
Step 1: Identify the auth middleware(s)
pracht inspect routes --json
MCP: when the pracht MCP server is registered (pracht dev-mcp), prefer its
inspect_routes/inspect_api/inspect_build/doctor/verify tools over
shelling out.
inspect reports middleware names, not files. Resolve them according to the
app's router mode, then read each middleware file and classify it:
- Manifest router: read the name→file map from
src/routes.ts(or the configured manifest), where middleware is registered throughdefineApp({ middleware: { auth: () => import("./middleware/auth.ts") } }). - Pages router: when the Vite config sets
pagesDir, the generated name"pages"resolves to the root<pagesDir>/_middleware.ts(or.tsx,.js, or.jsx). It applies to every page route and never wraps API routes. There is nosrc/routes.tsmanifest to inspect unless the app has ejected. Follow imports and re-exports into underscore-reserved helpers such as<pagesDir>/_server/auth.ts; Pracht excludes those helpers from client route/shell registries, but a direct import from client code still bundles them and should be treated as a server-code leak.
Then classify each resolved middleware module:
- Gate — on auth failure, returns a short-circuit
Response(redirect("/login", { request }), or a 401/403Response) WITHOUT callingnext(); on success,return next().requireSession()from@pracht/sessionis one. - Augmenter — puts user info on
context(or, in older code, on request headers), then always returnsnext(). Never short-circuits.sessionMiddleware()is one. - Other — non-auth middleware (rate limit, logging, CORS, etc.).
Flag any middleware that writes identity onto args.request.headers: the
client controls request headers, so a loader reading x-user-id back out is
trusting attacker-supplied input, and the write throws outright on Cloudflare
Workers where the incoming Request is immutable. Identity belongs on
context.
The "Augmenter" category is the silent killer: it makes loaders think
auth is enforced because request.headers.get('x-user-id') returns a value
when present, but unauthenticated requests just get null and the loader has
to handle it. Flag every loader downstream of an Augmenter that doesn't.
A shell loader that redirects anonymous users is not a gate either. A
client that already holds the shell's data sends x-pracht-shell-data: <shell>
on route-state requests and the server skips the shell loader, so the route's
data comes back without it running. Flag any shell whose loader is the only
thing between an anonymous request and the routes under it; the gate belongs
in middleware.
Step 2: Identify protected routes
A route is "expected protected" if any of:
- It has
auth/session/requireUser/similar middleware applied. - Its loader reads
context.session,getSession, or (legacy)x-user-id/x-user-email. - It lives under conventional protected paths:
/dashboard*,/admin*,/account*,/settings*,/app*(ask the user to confirm the convention if unclear). - The user has flagged it explicitly.
Build a list of expected-protected routes.
Step 3: Check coverage per protected route
For each expected-protected route:
- From
pracht inspect routes --json, read the resolvedmiddlewarearray. - Confirm at least one Gate middleware is present.
- Confirm the gate runs before any other middleware that depends on identity (order matters).
- If only an Augmenter is present, mark as
augmented-only. - For pages-router routes, inspect
rendertoo. A root pages Gate on anssgorisgroute does not protect the static document per visitor: document middleware runs during build/revalidation with a sanitized request, while later route-state requests are separate live requests. Unless an independently verified platform/CDN edge gate protects the document, report the route aserror/unprotected-staticand recommendssr/spafor session-gated pages.
Step 4: Check the API surface
Mutation endpoints (POST, PUT, PATCH, DELETE) are the highest-impact
target. From pracht inspect api --json:
- Each API route reports
path,file,methods, andhasDefaultHandler(the last requires a current@pracht/cli). Adefault-export handler serves ALL methods but reportsmethods: []— treathasDefaultHandler: trueas "every method exposed". On older CLIs where the field is missing, grep the handler file forexport defaultinstead. - For each mutation handler (named method export or default handler) and each
HTTP- or remote-MCP-exposed capability, check whether
defineApp({ api: { middleware } })applies a Gate, OR the handler/capability reads and validates a session itself. App-level API middleware wraps generated capability endpoints before capability-specific middleware. - For remote MCP, cookie-bearing transport requests are rejected before
capability dispatch and only
Authorizationis forwarded. Flag MCP-exposed capabilities whose gate depends on a browser session cookie or a custom credential header that the projection does not carry. - Treat
context.agentas framework-owned, read-only verified identity. Flag middleware or capability code that attempts to mutate or replace it instead of deriving application authorization state on a separate context field. - When a custom adapter supplies a frozen or sealed context, flag authorization
helpers that read
agentor middleware-added fields throughthis. The framework binds private-field methods to the immutable source receiver, which cannot observe fields added on its extensible overlay. Callable fields keep their own API and arrays keep their brand. Application-definedSymbol.toStringTagbranding does not affect whether an ordinary context can be overlaid, but immutable native built-ins such asMapandDatefail closed because an overlay cannot preserve their internal slots. Use a fresh mutable wrapper when a context needs native built-ins or when receiver-bound helpers depend on request state. - Inspect every HTTP-, WebMCP-, or MCP-exposed capability body for
invokeCapability(). Direct composition never re-applies app-level API middleware. Remote MCP additionally re-applies the callee'sagentPolicyand refuses destructive callees unless the tool being served is itself a destructive capability that already cleared prepare/commit — a request-scoped grant over every destructive callee, private ones included, so audit a confirmed destructive tool's body the way you would a confirmed HTTP endpoint's. Private non-destructive capabilities stay composable and rely on their named middleware for authorization. For HTTP/WebMCP composition, flag sensitive callees whose required transport authorization or approval is absent from the composing capability and the callee's named middleware. - Common bug: dashboard route is protected by middleware, but
POST /api/itemsis not — attacker bypasses the UI entirely.
Server islands
Every module in src/server-islands/ (or pracht({ serverIslandsDir })) is
reachable at GET /__pracht/server-island with the visitor's cookies, for any
page whose route or shell lists it in export const serverIslands = [...] —
with that route's middleware
(see https://pracht.resynapse.dev/docs/server-islands#security). Map each
server island to the routes that list it, directly or through their shell.
- Flag a server island listed by both gated and ungated routes whose loader
returns user-specific data without checking
contextitself —error: the ungated route serves it to anyone. - Flag pages that gate a server island only at render time
(
{isAdmin && <AdminStats />}behind a loader check, not middleware) —error: the endpoint runs it for anyone who passes that route's middleware. - Flag server island loaders that pick whose data to load from
props(userId,orderId,accountId) — the caller controls the query string (error, IDOR). Identity must come fromcontext. - Flag server island loaders with side effects (writes, sends) — the endpoint is
a
GET(warn).
Step 5: Client/server enforcement parity
Grep client components for patterns like if (!user) return <Login />. For
each occurrence, confirm that the data path is also gated server-side.
Client-side gating without a server gate is purely cosmetic and a common
source of "I see the data flash before redirect" or worse, leaked data via
SPA route loaders.
Step 6: Session cookie sanity
With @pracht/session, check the configuration rather than the mechanics:
cookie.secrets read from serverEnv (never a literal), the storage built
inside a function rather than at module scope (Workers env is request-scoped),
a __Host- cookie name unless subdomain sharing is required, and sameSite
matching the app's embedding needs. A store is required for logout to
invalidate a session anywhere other than the browser that asked.
Then check the login path for session.regenerate(), called after
credentials verify and before the user is written onto the session. Its
absence is session fixation: with a store, an attacker who can plant a cookie
for the host keeps a valid pointer to the session that becomes authenticated.
Flag it as error/fixation on any store-backed app; on a cookie-only app it
is info (the cookie carries the sealed data, not a pointer), but still worth
adding before the app grows a store. Audit every other privilege change the
same way — 2FA completion, role assumption, impersonation.
Cross-reference with audit-csrf: the same cookies that authorize the user
are the CSRF target. Recommend running audit-csrf after this skill.
Step 7: Report
| Route/API | Expected | Resolved middleware | Gate present? | Severity | Verdict |
|---|
Severity is the primary scale; the verdict is a secondary domain label:
error/unprotected— no auth middleware on a route the user expects protected.error/inconsistent— UI route is gated; sibling API is not.error/unprotected-static— a pages-router SSG/ISG document relies on request/session middleware without an independent per-request edge gate.warn/augmented-only— middleware reads session but never blocks; loader must handle null user.warn/client-only— server allows; client hides UI.error/fixation— store-backed session, noregenerate()at login.info/protected— gate confirmed.info/public-by-design— deliberately exposed (login, signup, marketing).
Rules
- The framework's
pracht inspect routes --jsonandpracht inspect api --jsonare the source of truth — group inheritance is already resolved. - Recognize Gates by behavior (short-circuits with a
Responsewithout callingnext()on failure), not by filename — projects useauth.ts,requireUser.ts,session.ts, etc. - An Augmenter is a valid pattern when paired with a separate Gate or a loader that explicitly handles the unauthenticated case. Flag it; don't condemn it.
- Public routes deliberately exposed (login, signup, marketing) should be listed but not flagged.
- Do not auto-add middleware. Auth wiring is policy.
- Treat allowed composed capability reachability as transitive. MCP blocks
destructive callees unless the served tool already cleared its own
confirmation gate, and re-applies
agentPolicy; named middleware remains the authorization seam for private non-destructive composition. Audit events identify every nested attempt withtransport: "server"and trusted request provenance invia, but observability is not an authorization gate.
$ARGUMENTS
Files (pracht)
-
SKILL.md 13.5 KB
--- name: audit-auth version: 1.5.0 description: | Find pracht routes that look protected but aren't: missing auth middleware, middleware that augments context but never gates, client-only checks, and unguarded API mutations. Use for "audit auth", "check route protection", "find unauthenticated routes", "review middleware coverage". allowed-tools: - Bash - Read - Grep - Glob --- # Pracht Audit Auth What the framework guarantees: middleware runs wrap-around style — every middleware must return a `Response` (the runtime throws if it doesn't), either `return next()` to continue the chain or a short-circuit `Response` to stop it. The `redirect()` helper from `@pracht/core` returns a scheme/CRLF-validated redirect `Response`. What the framework does NOT decide is *which* routes get an auth gate — that is app wiring, and this skill audits it. The pracht auth pattern (see <https://pracht.resynapse.dev/docs/recipes/auth>): middleware loads the session onto `context.session` and short-circuits with a redirect when there is no user; loaders downstream read `context.session`. `@pracht/session` is the first-party implementation. Two of its exports map straight onto the Gate/Augmenter classification below — `requireSession()` is a Gate, `sessionMiddleware()` is an Augmenter — so a project using it can be classified from the factory name without reading the middleware body. A project that hand-rolls its session instead is not automatically wrong, but check it for the things the package handles: an expiry inside the signed payload (not only `Max-Age`), a constant-time or `crypto.subtle.verify` signature check, `HttpOnly`/`Secure`/`SameSite`, and encryption if the cookie carries anything beyond an opaque id. Prerequisites: `pracht inspect` requires a vite config that registers the pracht plugin. ## Step 1: Identify the auth middleware(s) ```bash pracht inspect routes --json ``` MCP: when the pracht MCP server is registered (`pracht dev-mcp`), prefer its `inspect_routes`/`inspect_api`/`inspect_build`/`doctor`/`verify` tools over shelling out. `inspect` reports middleware names, not files. Resolve them according to the app's router mode, then read each middleware file and classify it: - **Manifest router:** read the name→file map from `src/routes.ts` (or the configured manifest), where middleware is registered through `defineApp({ middleware: { auth: () => import("./middleware/auth.ts") } })`. - **Pages router:** when the Vite config sets `pagesDir`, the generated name `"pages"` resolves to the root `<pagesDir>/_middleware.ts` (or `.tsx`, `.js`, or `.jsx`). It applies to every page route and never wraps API routes. There is no `src/routes.ts` manifest to inspect unless the app has ejected. Follow imports and re-exports into underscore-reserved helpers such as `<pagesDir>/_server/auth.ts`; Pracht excludes those helpers from client route/shell registries, but a direct import from client code still bundles them and should be treated as a server-code leak. Then classify each resolved middleware module: - **Gate** — on auth failure, returns a short-circuit `Response` (`redirect("/login", { request })`, or a 401/403 `Response`) WITHOUT calling `next()`; on success, `return next()`. `requireSession()` from `@pracht/session` is one. - **Augmenter** — puts user info on `context` (or, in older code, on request headers), then always returns `next()`. Never short-circuits. `sessionMiddleware()` is one. - **Other** — non-auth middleware (rate limit, logging, CORS, etc.). Flag any middleware that writes identity onto `args.request.headers`: the client controls request headers, so a loader reading `x-user-id` back out is trusting attacker-supplied input, and the write throws outright on Cloudflare Workers where the incoming `Request` is immutable. Identity belongs on `context`. The "Augmenter" category is the silent killer: it makes loaders *think* auth is enforced because `request.headers.get('x-user-id')` returns a value when present, but unauthenticated requests just get `null` and the loader has to handle it. Flag every loader downstream of an Augmenter that doesn't. A shell `loader` that redirects anonymous users is **not** a gate either. A client that already holds the shell's data sends `x-pracht-shell-data: <shell>` on route-state requests and the server skips the shell loader, so the route's data comes back without it running. Flag any shell whose loader is the only thing between an anonymous request and the routes under it; the gate belongs in middleware. ## Step 2: Identify protected routes A route is "expected protected" if any of: - It has `auth`/`session`/`requireUser`/similar middleware applied. - Its loader reads `context.session`, `getSession`, or (legacy) `x-user-id`/`x-user-email`. - It lives under conventional protected paths: `/dashboard*`, `/admin*`, `/account*`, `/settings*`, `/app*` (ask the user to confirm the convention if unclear). - The user has flagged it explicitly. Build a list of expected-protected routes. ## Step 3: Check coverage per protected route For each expected-protected route: 1. From `pracht inspect routes --json`, read the resolved `middleware` array. 2. Confirm at least one **Gate** middleware is present. 3. Confirm the gate runs **before** any other middleware that depends on identity (order matters). 4. If only an Augmenter is present, mark as `augmented-only`. 5. For pages-router routes, inspect `render` too. A root pages Gate on an `ssg` or `isg` route does not protect the static document per visitor: document middleware runs during build/revalidation with a sanitized request, while later route-state requests are separate live requests. Unless an independently verified platform/CDN edge gate protects the document, report the route as `error` / `unprotected-static` and recommend `ssr`/`spa` for session-gated pages. ## Step 4: Check the API surface Mutation endpoints (`POST`, `PUT`, `PATCH`, `DELETE`) are the highest-impact target. From `pracht inspect api --json`: - Each API route reports `path`, `file`, `methods`, and `hasDefaultHandler` (the last requires a current `@pracht/cli`). A `default`-export handler serves ALL methods but reports `methods: []` — treat `hasDefaultHandler: true` as "every method exposed". On older CLIs where the field is missing, grep the handler file for `export default` instead. - For each mutation handler (named method export or default handler) and each HTTP- or remote-MCP-exposed capability, check whether `defineApp({ api: { middleware } })` applies a Gate, OR the handler/capability reads and validates a session itself. App-level API middleware wraps generated capability endpoints before capability-specific middleware. - For remote MCP, cookie-bearing transport requests are rejected before capability dispatch and only `Authorization` is forwarded. Flag MCP-exposed capabilities whose gate depends on a browser session cookie or a custom credential header that the projection does not carry. - Treat `context.agent` as framework-owned, read-only verified identity. Flag middleware or capability code that attempts to mutate or replace it instead of deriving application authorization state on a separate context field. - When a custom adapter supplies a frozen or sealed context, flag authorization helpers that read `agent` or middleware-added fields through `this`. The framework binds private-field methods to the immutable source receiver, which cannot observe fields added on its extensible overlay. Callable fields keep their own API and arrays keep their brand. Application-defined `Symbol.toStringTag` branding does not affect whether an ordinary context can be overlaid, but immutable native built-ins such as `Map` and `Date` fail closed because an overlay cannot preserve their internal slots. Use a fresh mutable wrapper when a context needs native built-ins or when receiver-bound helpers depend on request state. - Inspect every HTTP-, WebMCP-, or MCP-exposed capability body for `invokeCapability()`. Direct composition never re-applies app-level API middleware. Remote MCP additionally re-applies the callee's `agentPolicy` and refuses destructive callees unless the tool being served is itself a destructive capability that already cleared prepare/commit — a request-scoped grant over every destructive callee, private ones included, so audit a confirmed destructive tool's body the way you would a confirmed HTTP endpoint's. Private non-destructive capabilities stay composable and rely on their named middleware for authorization. For HTTP/WebMCP composition, flag sensitive callees whose required transport authorization or approval is absent from the composing capability and the callee's named middleware. - Common bug: dashboard route is protected by middleware, but `POST /api/items` is not — attacker bypasses the UI entirely. ### Server islands Every module in `src/server-islands/` (or `pracht({ serverIslandsDir })`) is reachable at `GET /__pracht/server-island` with the visitor's cookies, for any page whose route or shell lists it in `export const serverIslands = [...]` — with that route's middleware (see <https://pracht.resynapse.dev/docs/server-islands#security>). Map each server island to the routes that list it, directly or through their shell. - Flag a server island listed by both gated and ungated routes whose loader returns user-specific data without checking `context` itself — `error`: the ungated route serves it to anyone. - Flag pages that gate a server island only at render time (`{isAdmin && <AdminStats />}` behind a loader check, not middleware) — `error`: the endpoint runs it for anyone who passes that route's middleware. - Flag server island loaders that pick *whose* data to load from `props` (`userId`, `orderId`, `accountId`) — the caller controls the query string (`error`, IDOR). Identity must come from `context`. - Flag server island loaders with side effects (writes, sends) — the endpoint is a `GET` (`warn`). ## Step 5: Client/server enforcement parity Grep client components for patterns like `if (!user) return <Login />`. For each occurrence, confirm that **the data path is also gated server-side**. Client-side gating without a server gate is purely cosmetic and a common source of "I see the data flash before redirect" or worse, leaked data via SPA route loaders. ## Step 6: Session cookie sanity With `@pracht/session`, check the configuration rather than the mechanics: `cookie.secrets` read from `serverEnv` (never a literal), the storage built inside a function rather than at module scope (Workers env is request-scoped), a `__Host-` cookie name unless subdomain sharing is required, and `sameSite` matching the app's embedding needs. A `store` is required for logout to invalidate a session anywhere other than the browser that asked. Then check the **login path for `session.regenerate()`**, called after credentials verify and before the user is written onto the session. Its absence is session fixation: with a store, an attacker who can plant a cookie for the host keeps a valid pointer to the session that becomes authenticated. Flag it as `error`/`fixation` on any store-backed app; on a cookie-only app it is `info` (the cookie carries the sealed data, not a pointer), but still worth adding before the app grows a store. Audit every other privilege change the same way — 2FA completion, role assumption, impersonation. Cross-reference with `audit-csrf`: the same cookies that authorize the user are the CSRF target. Recommend running `audit-csrf` after this skill. ## Step 7: Report | Route/API | Expected | Resolved middleware | Gate present? | Severity | Verdict | | --------- | -------- | ------------------- | ------------- | -------- | ------- | Severity is the primary scale; the verdict is a secondary domain label: - `error` / `unprotected` — no auth middleware on a route the user expects protected. - `error` / `inconsistent` — UI route is gated; sibling API is not. - `error` / `unprotected-static` — a pages-router SSG/ISG document relies on request/session middleware without an independent per-request edge gate. - `warn` / `augmented-only` — middleware reads session but never blocks; loader must handle null user. - `warn` / `client-only` — server allows; client hides UI. - `error` / `fixation` — store-backed session, no `regenerate()` at login. - `info` / `protected` — gate confirmed. - `info` / `public-by-design` — deliberately exposed (login, signup, marketing). ## Rules 1. The framework's `pracht inspect routes --json` and `pracht inspect api --json` are the source of truth — group inheritance is already resolved. 2. Recognize Gates by behavior (short-circuits with a `Response` without calling `next()` on failure), not by filename — projects use `auth.ts`, `requireUser.ts`, `session.ts`, etc. 3. An Augmenter is a valid pattern when paired with a separate Gate or a loader that explicitly handles the unauthenticated case. Flag it; don't condemn it. 4. Public routes deliberately exposed (login, signup, marketing) should be listed but not flagged. 5. Do not auto-add middleware. Auth wiring is policy. 6. Treat allowed composed capability reachability as transitive. MCP blocks destructive callees unless the served tool already cleared its own confirmation gate, and re-applies `agentPolicy`; named middleware remains the authorization seam for private non-destructive composition. Audit events identify every nested attempt with `transport: "server"` and trusted request provenance in `via`, but observability is not an authorization gate. $ARGUMENTS
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.