Middleware Enforcement

Code-based enforcement for Next.js, Express, ASP.NET Core, and Java applications

What is Middleware Enforcement?

Checkpoint Middleware adds AI agent detection and enforcement directly in your application code. It runs before your route handlers, classifying every request and applying the verdict from the Rust kya-os-engine (plus any composed Cedar policy you deploy).

Middleware is available for Next.js, Express, ASP.NET Core, and Java applications.

Next.js has two shapes. withCheckpoint runs the detection engine in-process (WASM, no per-request network hop). withCheckpointApi dispatches to the Checkpoint SaaS gateway over HTTPS (no local WASM). Express, .NET, and Java run the engine in-process. Pick the shape that fits your runtime. See Basic Setup below.

Prerequisites

  • A Checkpoint project. Create one in the dashboard if you don't have one yet.
  • Your project's API key (and, for the .NET and Java adapters, its Project ID). See Credentials for where to find both in the dashboard.
  • tenantHost: the one required config field for the Next.js local-engine withCheckpoint and for Express: your dashboard hostname, used to look up the deployed policy. withCheckpointApi (Next.js SaaS gateway), .NET, and Java use apiKey/ProjectId instead of tenantHost. See Configuration Options below.

Installation

npm install @kya-os/checkpoint-nextjs

Basic Setup

Create or update middleware.ts in your project root. Choose the deployment shape for your runtime:

Next.js 16: middleware.ts → proxy.ts

In Next.js 16 this file convention was renamed. Name the file proxy.ts and export a proxy function (a default export also works); middleware.ts exporting middleware still works but is deprecated. The Checkpoint setup below is identical either way: only the file name and export name change. One caveat: proxy.ts runs on the Node.js runtime only, so if you want Checkpoint on the Edge runtime (lowest latency), keep the file as middleware.ts. On Next.js 15 and earlier, use middleware.ts.

Local engine (withCheckpoint): runs the WASM engine in-process. Lowest latency, deterministic verdicts, no per-request network hop.

// middleware.ts
import { withCheckpoint } from '@kya-os/checkpoint-nextjs';

export default withCheckpoint({
  // Vercel only. Self-hosted: use 'trusted-proxy' with trustedHops set to your proxy count minus one.
  clientIpPolicy: { platform: 'vercel' },
  tenantHost: 'your.tenant.example',
  apiKey: process.env.CHECKPOINT_API_KEY, // optional: enables dashboard reporting
});

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
};

SaaS gateway (withCheckpointApi): dispatches detection + enforcement to the Checkpoint gateway over HTTPS. No local WASM; centralized dashboard policy applies.

// middleware.ts
import { withCheckpointApi } from '@kya-os/checkpoint-nextjs/api-middleware';

export default withCheckpointApi({
  // Vercel only. Self-hosted: use 'trusted-proxy' with trustedHops set to your proxy count minus one.
  clientIpPolicy: { platform: 'vercel' },
  apiKey: process.env.CHECKPOINT_API_KEY,
});

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
};

See the full Next.js integration guide for App Router / Pages Router specifics, API-route protection, and troubleshooting.

Configuration Options

The Next.js local-engine (withCheckpoint) and Express middleware share the same CheckpointConfig shape (Next.js adds a couple of runtime-specific fields). The SaaS-gateway middleware (withCheckpointApi) uses a different config. See SaaS gateway config below.

OptionTypeDefaultDescription
clientIpPolicyClientIpTrustPolicyNo default

Explicitly selects the proxy/platform trust boundary for client IPs.

tenantHostRequiredstringNo default

Tenant identifier — typically the customer's dashboard hostname (e.g. acme.checkpoint.example). The PolicyEvaluator uses this to look up tenant policy from the dashboard.

resourceOrgDidstringNo default

Exact org DID used to verify the consent credential's audience.

trustedDelegationRootsstring[]No default

Explicit trusted delegation issuers; otherwise resolved from project policy.

enforcementModeEnforcementMode'enforce'

'enforce' (default) blocks; 'observe' passes everything through with X-Checkpoint-Would-Have-Been headers. Per Phase 0.2.

delegationChallengeModeChallengeEnvelopeMode'negotiated'

Challenge-emission envelope mode (draft-kya-http-02 §8.1 fetcher-200). negotiated serves a body-readable HTTP 200 step-up response to cooperative agents (fetchers that drop 4xx/5xx bodies), keeping the spec 401/422 for everyone else. Defaults to negotiated (#3530). A cooperative-UX bridge, NOT an access control.

resolveDelegationChallenge
function
(context: { result: VerifyResult; request: Request; }) => ChallengeWireInput | undefined | Promise<ChallengeWireInput | undefined>
No default

Resolve a policy Challenge into complete registered wire context. The callback performs any Authorization-Host registration asynchronously; the synchronous engine renderer never fabricates a sid, consent URI, or proof.

delegationChallengeTimeoutMsnumber3000

Registration/resolution budget in milliseconds.

onDelegationChallengeFailure(event: ChallengeContextFailureEvent) => voidNo default

Sanitized registration failure hook for metrics and operator warnings.

argusUrlstringNo default

Argus reputation oracle base URL. Omit to use the trust-by-default baseline (reputation defaults to 1.0; orchestrator logs a one-shot warning at first request).

dashboardUrlstringNo default

Dashboard base URL for the PolicyEvaluator to fetch tenant policy from. Omit to use the open-by-default tenant policy.

reputationBaselinenumber1.0

Returned to the PolicyEvaluator for anonymous requests (no agent DID). Trust-by-default. Engine scale is 0.0 to 1.0.

adapters
Partial<object>
Partial<{ didResolver: DidResolverAdapter; statusListCache: StatusListCacheAdapter; reputationOracle: ReputationOracleAdapter; policyEvaluator: PolicyEvaluatorAdapter; }>
No default

Pre-built adapter instances. Production deployments use the factory-built defaults from @kya-os/checkpoint-wasm-runtime/ adapters; tests use stubs. The factory composes any provided overrides over defaults — partial overrides are supported.

onResult(result: VerifyResult, req: Request) => void | Promise<void>No default

Optional callback for the post-verdict path — fires after every verification, regardless of permit/block, with the full VerifyResult. Use for logging, dashboards, telemetry. Errors thrown here are swallowed so user code can't break the middleware response.

legacyEnvelopeFallbackbooleanfalse

Accept legacy KYA-Delegation-header envelope form alongside the canonical _meta.proof.jws body form. Default false.

When to enable — customers whose agents pre-date Envelope-1 (#2537) and ship KYA-OS proofs as {protected,payload,signature} JSON in a KYA-Delegation HTTP header. Post-Envelope-1 agents ship compact JWS in the request body's _meta.proof.jws field; those don't need this flag (Express's body-parser pre-populates req.body which translate.ts already forwards to the orchestrator).

Forwarded to the orchestrator's VerifyRequestOpts.legacyEnvelopeFallback.

SDK-Envelope-Plumbing-1 (#2594). Added in @kya-os/checkpoint-express@1.1.0.

engineConfigEngineConfig{ tier3Action: 'monitor' }

Engine-default behaviour knobs forwarded to every composed ContextSpec. Defaults to { tier3Action: 'monitor' } — customer-onboarding-safe (tenant policy decides; engine doesn't short-circuit known-agent UAs with an engine-default Block).

Opt into { tier3Action: 'block' } when the host wants the calibrated engine-default block for KnownAiAgent / AiCrawler / HeadlessBrowser classifications BEFORE the tenant policy seam. The bench harness is the canonical opt-in consumer.

Added in @kya-os/checkpoint-express@1.2.0 (Engine-Tier3-Monitor- Default, #2653 + this PR's plumbing follow-up).

apiKeystringNo default

Project API key. Required for detections to land in the dashboard — the engine verifies in-process via WASM, but the resulting VerifyResult only reaches the dashboard's detections table when this reporter is configured. Without it the verdict path works locally but the onboarding "Verify connection" check fails forever because no rows are ever written.

Resolve from process.env.CHECKPOINT_API_KEY in the host app.

Added in @kya-os/checkpoint-express@1.4.0 (SDK-Detection-Reporter-1).

baseUrlstring'https://kya.vouched.id'

Dashboard base URL. Override for staging or self-hosted dashboards.

debugbooleanfalse

Surface reporter errors via console.warn. Defaults to false. The reporter is fire-and-forget; enable during development to confirm apiKey / baseUrl are routed correctly.

Also wires the composed-policy shadow-divergence + fail-open telemetry to console.warn/console.error (otherwise silent).

projectIdstringNo default

Project id whose composed (/policy-compose) policy this middleware enforces. When set, the project's policy is fetched from the dashboard (<dashboardUrl ?? baseUrl ?? default>/api/internal/policies/${projectId}) and — if it carries a deployed Cedar bundle with engineEnforcementEnabled on — the kya-os-engine decision is enforced IN-PROCESS, byte-for-byte the same as the DNS Gateway. Omit to run detection + the structured policy only (fully back-compatible; this is purely additive).

SHADOW-FIRST: with a deployed bundle but engineEnforcementEnabled off, the engine decision is computed + logged on divergence but does NOT act. Get the project id from the dashboard (same place as apiKey).

Added in @kya-os/checkpoint-express@1.5.0 (@Policy middleware-Cedar, #3076).

browserPostureBrowserPostureConfigNo default

Signed browser risk; configure explicit route scopes to enable consent step-up.

policyCacheTtlSecondsnumber300

Policy-fetch cache TTL in seconds. Defaults to 300 (5 minutes). How long a fetched project policy is reused before the middleware refetches from the dashboard — i.e. the worst-case delay before a dashboard policy change takes effect on this host.

0 disables reuse entirely: every request fetches the policy (one origin round-trip per request). Use for demo/example sites where instant policy propagation matters more than latency; keep the default (or a small positive value like 5) for production and benchmark hosts.

composedPolicyEnforcerComposedPolicyEnforcerNo default

Advanced / testing: inject a pre-built composed-policy enforcer instead of letting withCheckpoint construct one from projectId + baseUrl + apiKey. Mirrors the adapters injection philosophy — production omits this. When set, it takes precedence over projectId.

Next.js withCheckpoint additionally accepts cedarWasmModule for the Edge runtime, and composedPolicyEnforcer for injecting a pre-built enforcer: both advanced-injection escapes rather than everyday configuration.

There is no mode: 'strict' | 'balanced' | 'lenient' knob, no onAgentDetected / onBlock callback, and no top-level storage or allowList config on withCheckpoint. Detection sensitivity comes from the engine's calibrated per-pattern scoring; enforcement rules come from your policies / composed Cedar; agent allowances come from the deployed policy, not a config array.

Detect-only (observe) mode

To run detection without blocking, set enforcementMode: 'observe'. Every request passes through; the engine still classifies each one and stamps X-Checkpoint-Would-Have-Been headers so you can see what enforcement would have done, and detections still report to the dashboard (when apiKey is set).

withCheckpoint({
  tenantHost: 'your.tenant.example',
  enforcementMode: 'observe', // classify + report, never block
  apiKey: process.env.CHECKPOINT_API_KEY,
});

SaaS gateway config (Next.js)

withCheckpointApi is a distinct middleware with its own configuration; it POSTs to the gateway rather than running the engine locally, so its knobs differ from CheckpointConfig:

OptionTypeDefaultDescription
clientIpPolicyClientIpTrustPolicyNo default

Explicit deployment/proxy trust profile for client-IP resolution.

apiKeystringNo default

API key (or use CHECKPOINT_API_KEY env var)

apiUrlstring'https://detect.checkpoint-gateway.ai'

Gateway base URL. Override for staging or self-hosted deployments. With useEdge: false the default becomes https://kya.vouched.id.

useEdgebooleantrue

Use edge detection for lower latency (~30-50ms vs ~150ms) and better coverage. Edge detection can identify non-JS clients (curl, Python, Claude Code WebFetch) that the pixel cannot detect since they don't execute JavaScript. Set to false to use the Vercel API instead.

timeoutnumber5000

Request timeout in milliseconds for the gateway call.

onBlock"block" | "challenge" | "redirect"No default

Action to take when an agent should be blocked

  • 'block': Return 403 response
  • 'redirect': Redirect to redirectUrl
  • 'challenge': Show a challenge page (future) Default: uses policy from dashboard
redirectUrlstringNo default

URL to redirect to when blocking (if onBlock is 'redirect') Default: uses redirectUrl from dashboard policy

redirectMode"http" | "instruct"'instruct'

How the middleware handles a redirect / instruct action.

  • 'instruct' (default): return HTTP 401 with a KYA-OS Link header + JSON body pointing the agent at the redirect URL. LLMs surface the URL as a clickable link for the user. Matches the Cloudflare Gateway contract.
  • 'http': legacy behavior — return HTTP 302 with Location. Most LLM fetchers won't follow the redirect, so this is only useful when your traffic is real browsers.
delegationChallengeModeChallengeEnvelopeMode'negotiated'

Challenge-emission envelope mode (draft-kya-http-02 §8.1 fetcher-200). negotiated serves a body-readable HTTP 200 instruct response to cooperative agents (so fetchers that drop 4xx bodies can read it), keeping the spec 401 for everyone else. Defaults to negotiated (#3530). A cooperative-UX bridge, NOT an access control.

blockedResponse
object
{ status?: number; message?: string; headers?: Record<string, string>; }
{ status: 403, message: 'Access denied' }

Customize the blocked response's status, message, and extra headers.

skipPathsstring[]No default

Paths to skip (in addition to dashboard policy) Supports glob patterns: '/api/', '/_next/'

includePathsstring[]No default

Only enforce on these paths (overrides dashboard policy)

onAgentDetected(request: NextRequest, decision: EnforcementDecision) => void | Promise<void>No default

Callback when an agent is detected

customBlockedResponse
function
(request: NextRequest, decision: EnforcementDecision) => NextResponse | Promise<NextResponse>
No default

Callback to customize the blocked response

failOpenbooleantrue

Whether to fail open (allow) on API errors. Recommended for production.

debugbooleanfalse

Log detection decisions and errors to the console.

onBlock and onAgentDetected belong to withCheckpointApi (the SaaS-gateway path) only. The in-process withCheckpoint engine does not have them: read its verdict via the onResult callback instead (see below).

Reading the Verdict

Plain withCheckpoint attaches nothing to req. Read the engine's verdict through the onResult(result, req) callback, which fires after every verification with the full VerifyResult:

withCheckpoint({
  tenantHost: 'your.tenant.example',
  onResult: (result, req) => {
    // result.decision.kind is the verdict: 'Permit' | 'Block' | 'Challenge' | 'Redirect' | 'Instruct'
    console.log(`[${req.method} ${req.url}] verdict=${result.decision.kind}`);
  },
});

Errors thrown inside onResult are swallowed so an observability failure can never break the verdict path.

Observability & Storage (optional)

The session-tracking and event-storage primitives from the retired "enhanced" middleware are now composable exports you wire into withCheckpoint yourself. There is no separate enhanced middleware tier. This section is Express-specific.

Using .NET? Session tracking and signature verification are built into the base ASP.NET Core package: set EnableSessionTracking = true (the default) on CheckpointOptions. No extra wiring is needed.

Storage adapters

@kya-os/checkpoint-express exports MemoryStorageAdapter, RedisStorageAdapter, and an async createStorageAdapter factory. Construct an adapter and record events from onResult:

import { withCheckpoint, createStorageAdapter } from '@kya-os/checkpoint-express';

// createStorageAdapter is async and returns a StorageAdapter.
const storage = await createStorageAdapter({
  type: 'redis',
  ttl: 86400,
  redis: {
    url: process.env.REDIS_URL!,
    token: process.env.REDIS_TOKEN!,
  },
});

app.use(
  withCheckpoint({
    tenantHost: 'your.tenant.example',
    onResult: async (result, req) => {
      await storage.storeEvent({
        eventId: crypto.randomUUID(),
        sessionId: req.headers['x-request-id']?.toString() ?? crypto.randomUUID(),
        timestamp: new Date().toISOString(),
        agentType: result.detectionDetail.detectionClass.type,
        agentName: result.detectionDetail.detectedAgent?.name ?? 'unknown',
        confidence: result.detectionDetail.confidence,
        path: req.path,
        method: req.method,
        userAgent: req.headers['user-agent'],
        detectionReasons: result.detectionDetail.reasons,
        verificationMethod: result.detectionDetail.verificationMethod,
      });
    },
  })
);

createStorageAdapter({ type: 'memory' }) (or no argument) returns an in-memory adapter (fine for development, but data is lost on restart and not shared across instances). Use Redis (Upstash) for production.

The StorageAdapter interface

Implement this interface to plug in your own backend ({ type: 'custom', custom: myAdapter }):

import type { StorageAdapter } from '@kya-os/checkpoint-express';

const myAdapter: StorageAdapter = {
  storeEvent: async (event) => {
    /* persist an AgentDetectionEvent */
  },
  storeSession: async (session) => {
    /* upsert an AgentSession */
  },
  getEvents: async (sessionId, limit) => {
    /* events for a session */ return [];
  },
  getSession: async (sessionId) => {
    /* one session or null */ return null;
  },
  getRecentEvents: async (limit) => {
    /* recent events */ return [];
  },
  getActiveSessions: async (limit) => {
    /* active sessions */ return [];
  },
  cleanup: async (before) => {
    /* optional: clear data older than `before` */
  },
};

Session tracking

The Express package also exports ExpressSessionTracker and withSessionTracking for cookie/header-based session continuity. withSessionTracking(middleware, { enabled: true }) wraps a middleware so a returning agent's session is attached to req.checkpoint on subsequent requests. Install and mount cookie-parser ahead of it; the tracker reads req.cookies, which is only populated once something parses the Cookie header:

import cookieParser from 'cookie-parser';
import { withCheckpoint, withSessionTracking } from '@kya-os/checkpoint-express';

app.use(cookieParser()); // required for cookie-based session continuity

const checkpoint = withCheckpoint({ tenantHost: 'your.tenant.example' });

app.use(withSessionTracking(checkpoint, { enabled: true }));

Without cookie-parser, the tracker still falls back to header-based continuity (a kya-session request header), so tracking degrades rather than breaks, but cookie continuity across requests needs it mounted.

req.checkpoint is a nested object, not a flat verdict:

req.checkpoint = {
  result: DetectionResult, // the detection/verdict payload
  skipped: boolean,
  session?: SessionData, // present only when a prior session was found on this request
};

req.checkpoint is populated only when you wire session tracking. Plain withCheckpoint does not attach anything to req: use onResult to read the verdict.

req.checkpoint landed in @kya-os/checkpoint-express 1.8.0 (current source version: 1.10.0). Check what you actually resolved with npm ls @kya-os/checkpoint-express and upgrade if you resolved anything earlier.

Response Headers

The engine path (withCheckpoint, Express and Next.js) sets X-Checkpoint-* headers on the response and writes a __checkpoint_verdict cookie that is byte-identical across both runtimes (shared encodeVerdictCookie primitive). In observe mode it adds X-Checkpoint-Would-Have-Been so you can see the verdict enforcement would have applied.

The X-Checkpoint-Engine header carries the engine name. Exact header keys are produced by the engine's renderDecisionAsResponse; the SDK propagates them verbatim.

When you enable Express session tracking, ExpressSessionTracker additionally emits kya-session, kya-session-agent, and kya-session-id (and withSessionTracking sets kya-detected / kya-agent on a continued session).

Earlier docs listed KYA-Detected / KYA-Confidence / KYA-Agent / KYA-Verification / KYA-AI-Visitor headers for the engine path. Those were stale. The in-process engine emits X-Checkpoint-*. The KYA-* detection headers belong to the SaaS-gateway path (withCheckpointApi), which sets KYA-Detected / KYA-Confidence / KYA-Agent on pass-through responses.

Response shape (Express)

The middleware adapts the engine's Decision to one of four response shapes. Express and the Next.js local engine (withCheckpoint) share the same transport-agnostic adapter (renderDecisionAsResponse → RenderedResponse), so the shape is identical across both runtimes except for the HTML-block transport, where Next.js's Edge/Node runtime offers a rewrite primitive Express doesn't:

VerdictExpressNext.js (withCheckpoint)
Permit / Observenext(): pass through, set verdict cookie + X-Checkpoint-* headersNextResponse.next(): pass through, set verdict cookie + X-Checkpoint-* headers
Redirectres.redirect(302, target)NextResponse.redirect(target): 302 + Location
Block + HTMLres.redirect(302, '/blocked'): your /blocked route reads the verdict cookieNextResponse.rewrite('/blocked', { status: 200 }): same verdict cookie, page renders in place instead of a second round trip
Block + non-HTMLres.status(<engine-status>).json(body) (4xx, for JSON-API clients)NextResponse.json(body, { status: <engine-status> }) (4xx, for JSON-API clients)

Nothing is attached to req / NextRequest on this path in either runtime: read the verdict from onResult (as shown above), or from the response's verdict cookie / X-Checkpoint-* headers.

withCheckpointApi (Next.js SaaS gateway) does not use this adapter. It never runs renderDecisionAsResponse; its response shapes come from the gateway's EnforcementDecision.action (block / redirect / instruct / challenge / log / allow), not the engine's Decision.kind. See below.

Response shape (Next.js SaaS gateway)

withCheckpointApi attaches nothing to req either. Read the verdict from the response, from the onAgentDetected(request, decision) callback (fires only when decision.isAgent), or from the decision object passed into customBlockedResponse:

decision.actionNext.js response
blockNextResponse.json(...) at blockedResponse.status (default 403) with KYA-Action / KYA-Reason headers; adds a Link: <url>; rel="kya-authorize" + KYA-Auth-Url header when a recovery URL is available
redirect / instructBy default, a 401 (or a body-readable 200 under negotiated delegationChallengeMode) with Link, KYA-Auth-Required, KYA-Auth-Url, KYA-Action, KYA-Detected-Agent, KYA-Confidence headers; no WWW-Authenticate (instruct isn't a registered Delegation step-up). redirectMode: 'http' sends a plain 302 instead.
challengeTreated as redirect (302)
log / allow (default)NextResponse.next(): adds KYA-Detected / KYA-Confidence / KYA-Agent headers when decision.isAgent

.NET Configuration

Configure the ASP.NET Core adapter through CheckpointOptions in AddCheckpoint(options => …). Selected options (see the .NET integration guide for the full list):

OptionTypeDefaultDescription
ApiKeystring?NoneDashboard API key (sk_…). Enables policy enforcement.
ProjectIdstring?NoneProject ID in the dashboard.
BaseUrlstringhttps://kya.vouched.idCheckpoint API base URL.
OnAgentDetectedDetectedActionDetectedAction.LogAction when a detected agent exceeds the confidence threshold. Values: Log, Block, Allow, Redirect, Instruct, Challenge.
ConfidenceThresholddouble70Minimum confidence (0–100) to trigger OnAgentDetected.
EnableSessionTrackingbooltrueTrack agent sessions across requests using a canonical session ID.
EnableSignatureVerificationbooltrueVerify Ed25519 signatures (ChatGPT RFC 9421, KYA-OS agents).
EnableComposedPolicybooltrueWire in-process composed /policy-compose Cedar evaluation (shadow-first).
Tier3ActionTier3ActionMonitorEngine-default behaviour for Tier-3 UA matches (Monitor / Block / Challenge).
McpServerUrlstring?NoneMCP server URL used as the redirect target when OnAgentDetected = Redirect.
FailOpenbooltrueAllow requests through on middleware error.

OnAgentDetected = DetectedAction.Block and EnableSessionTracking are real members of CheckpointOptions: verified against Checkpoint.Core. The .NET API deliberately keeps OnAgentDetected (an enum action) where the TS engine SDK uses composed policy + onResult; the two SDKs are configured differently by design.

Advanced Usage (Next.js)

API route protection (SaaS gateway)

withCheckpointApi(config) takes a config object and returns Next.js middleware. It is not a route-handler wrapper. To protect only your API routes, scope the middleware with the matcher config:

// middleware.ts
import { withCheckpointApi } from '@kya-os/checkpoint-nextjs/api-middleware';

export default withCheckpointApi({
  // Vercel only. Self-hosted: use 'trusted-proxy' with trustedHops set to your proxy count minus one.
  clientIpPolicy: { platform: 'vercel' },
  apiKey: process.env.CHECKPOINT_API_KEY,
});

export const config = {
  matcher: ['/api/:path*'],
};

Alternatively, keep a site-wide matcher and set includePaths: ['/api/*'] in the middleware config so only API paths are enforced.

Client-side detection

The middleware packages are server-side only. They do not ship client-side detection hooks. (The legacy useAgentDetection hook was removed along with the AgentDetector class it wrapped.) For client-side detection (conditionally rendering content, tracking agent visits from the browser), use the JavaScript Beacon or the Marketing Pixel alongside the middleware.

Route Matching (Next.js)

Control which routes the middleware applies to with the Next.js matcher config:

export const config = {
  matcher: [
    // Match all paths except static assets
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

Next.js's built-in matcher skip list covers _next (its own static/image-optimization paths) and favicon.ico, but not arbitrary image extensions: a /logo.png or /hero.jpg served straight out of public/ isn't under _next/ and isn't covered by that skip list, so without the trailing .*\.(?:svg|png|jpg|jpeg|gif|webp)$ alternation, requests for it flow through the middleware and get classified like any other page. Add the extension exclusion whenever your app serves images outside of next/image.

To protect only specific routes:

export const config = {
  matcher: ['/api/:path*', '/dashboard/:path*'],
};

Next Steps