Middleware Enforcement
Code-based enforcement for Next.js, Express, and ASP.NET Core 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, and ASP.NET Core 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 and .NET 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 adapter, its Project ID) — see Credentials for where to find both in the dashboard.
tenantHost— the one required config field for the Next.js local-enginewithCheckpointand for Express: your dashboard hostname, used to look up the deployed policy.withCheckpointApi(Next.js SaaS gateway) and .NET useapiKey/ProjectIdinstead oftenantHost— see Configuration Options below.
Installation
npm install @kya-os/checkpoint-nextjsnpm install @kya-os/checkpoint-expressThe .NET SDK ships as four packages, all published at the same version. Install the metapackage to pull the whole surface, or reference the adapter you need directly.
# Metapackage — NuGet picks the adapter for your TFM, Checkpoint.Core comes transitively
dotnet add package KyaOs.Checkpoint
# — or reference the pieces directly —
dotnet add package Checkpoint.AspNetCore # ASP.NET Core (net8) middleware
dotnet add package Checkpoint.Core # engine, options, detection types
# dotnet add package Checkpoint.AspNet # classic ASP.NET / IIS (net462) moduleCheckpoint.AspNetCore is the modern ASP.NET Core adapter; Checkpoint.AspNet is the classic .NET Framework (net462 / IIS) module. Both depend on Checkpoint.Core. KyaOs.Checkpoint ships no code of its own — it declares a TFM-conditional dependency so NuGet resolves Checkpoint.AspNetCore on net8.0 and Checkpoint.AspNet on net462, pulling Checkpoint.Core in transitively either way. Its resolved graph always matches the standalone packages exactly.
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
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({
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({
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.
Mount withCheckpoint with app.use(...). Every request flows through the in-process WASM engine.
import express from 'express';
import { withCheckpoint } from '@kya-os/checkpoint-express';
const app = express();
app.use(express.json()); // body-parser — required for KYA-OS proof-envelope parsing
app.use(
withCheckpoint({
tenantHost: 'your.tenant.example',
apiKey: process.env.CHECKPOINT_API_KEY, // optional — enables dashboard reporting
})
);
app.get('/', (_req, res) => {
res.json({ message: 'Protected by Checkpoint' });
});
app.listen(3000);That's the whole API. The engine handles UA pattern detection, KYA-OS signature verification, scope evaluation, delegation-chain verification, status-list lookups, and policy evaluation. The wrapper translates the Express request, calls the engine, and applies the verdict to the response.
See the full Express integration guide for storage adapters, session tracking, and troubleshooting.
// Program.cs
using Checkpoint.AspNetCore.Extensions;
using Checkpoint.Core.Configuration;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCheckpoint(options =>
{
options.ProjectId = builder.Configuration["Checkpoint:ProjectId"]!;
options.ApiKey = builder.Configuration["Checkpoint:ApiKey"]!;
options.OnAgentDetected = DetectedAction.Block;
});
var app = builder.Build();
app.UseCheckpoint(); // Add early — before UseRouting()
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.Run();See the full .NET integration guide for configuration options, signature verification, and KYA-OS instruct mode.
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.
| Option | Type | Default | Description |
|---|---|---|---|
tenantHost | string | Required | Tenant identifier (typically your dashboard hostname). Drives the PolicyEvaluator lookup. |
enforcementMode | 'enforce' | 'observe' | 'enforce' | 'enforce' blocks; 'observe' passes everything through with X-Checkpoint-Would-Have-Been headers. |
apiKey | string | — | Project API key. Required for detections to land in the dashboard. Resolve from process.env.CHECKPOINT_API_KEY. |
projectId | string | — | Enables in-process enforcement of the project's composed (/policy-compose) Cedar policy. Omit for detection + structured policy only. |
onResult | (result, req) => void | Promise | — | Post-verdict observability callback. Fires after every verification (permit or block). Thrown errors are swallowed. |
baseUrl | string | https://kya.vouched.id | Dashboard base URL. Override for staging or self-hosted dashboards. |
dashboardUrl | string | open-by-default | Tenant-policy source for the PolicyEvaluator. |
engineConfig | EngineConfig | { tier3Action: 'monitor' } | Engine-default behaviour knobs. Opt into { tier3Action: 'block' } for engine-default blocking of Tier-3 UA matches. |
delegationChallengeMode | 'spec-401' | 'negotiated' | 'always-200' | 'negotiated' | Challenge-envelope mode. negotiated serves a body-readable 200 step-up to cooperative agents, keeping the spec 401 for everyone else; spec-401 always emits the spec 401; always-200 always serves the 200 envelope. |
legacyEnvelopeFallback | boolean | false | Accept legacy KYA-Delegation-header envelopes alongside the canonical body form. |
drainJsonBody | boolean | true | Read a JSON request body so the orchestrator can extract the KYA-OS envelope from _meta.proof.jws. Disable only for streaming middlewares that can't absorb the one-body req.clone() copy — then route envelopes through the header transport, which needs legacyEnvelopeFallback: true. Next.js only. |
policyCacheTtlSeconds | number | 300 | Composed-policy fetch cache TTL. 0 refetches every request. |
reputationBaseline | number | 1.0 | Reputation returned for anonymous requests (trust-by-default). Engine scale: 0.0–1.0. |
argusUrl | string | trust-by-default | Argus reputation-oracle base URL. Scores at this layer are 0.0–1.0; registry/Bouncer surfaces use 0–100 — see Proofs. |
adapters | Partial<{...}> | factory defaults | Override the built-in DID resolver / status-list cache / reputation oracle / policy evaluator (tests). |
debug | boolean | false | Surface reporter + composed-policy telemetry via console.warn / console.error. |
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:
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | env var | API key (or CHECKPOINT_API_KEY). |
apiUrl | string | https://detect.checkpoint-gateway.ai (or https://kya.vouched.id if useEdge: false) | Override the gateway base URL — for staging or self-hosted deployments. |
useEdge | boolean | true | Dispatch to the edge detection gateway (lower latency, catches non-JS clients). Set false to call the Vercel-hosted API instead. |
timeout | number | 5000 | Request timeout in ms for the gateway call. |
onBlock | 'block' | 'redirect' | 'challenge' | dashboard | Override the action taken when the gateway decides to block. |
redirectUrl | string | dashboard | Target when onBlock: 'redirect'. |
redirectMode | 'instruct' | 'http' | 'instruct' | instruct returns a 401 + KYA-OS challenge Link header; http returns a 302. |
delegationChallengeMode | 'spec-401' | 'negotiated' | 'always-200' | 'negotiated' | Challenge-envelope mode for the redirect/instruct response — a cooperative-UX bridge, not an access control. Same semantics as the CheckpointConfig field of the same name above. |
blockedResponse | { status?, message?, headers? } | { status: 403, message: 'Access denied' } | Customize the blocked response's status, message, and extra headers. |
onAgentDetected | (request, decision) => void | Promise | — | Observability callback fired when the gateway detects an agent. |
customBlockedResponse | (request, decision) => NextResponse | Promise | — | Fully replace the blocked response with your own. |
skipPaths | string[] | static assets | Extra paths to skip (glob patterns supported), added to the built-in static-asset skip list. |
includePaths | string[] | — | If set, only these paths are enforced. |
failOpen | boolean | true | Allow requests through on API error. |
debug | boolean | false | 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.8.0). Check what you actually resolved with npm ls @kya-os/checkpoint-express — on
anything earlier, read the same object as req.agentShield. That name remains supported as a
deprecated alias and points at the identical object, so it's the safe choice if you need code that
works on both.
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:
| Verdict | Express | Next.js (withCheckpoint) |
|---|---|---|
| Permit / Observe | next() — pass through, set verdict cookie + X-Checkpoint-* headers | NextResponse.next() — pass through, set verdict cookie + X-Checkpoint-* headers |
| Redirect | res.redirect(302, target) | NextResponse.redirect(target) — 302 + Location |
| Block + HTML | res.redirect(302, '/blocked') — your /blocked route reads the verdict cookie | NextResponse.rewrite('/blocked', { status: 200 }) — same verdict cookie, page renders in place instead of a second round trip |
| Block + non-HTML | res.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.action | Next.js response |
|---|---|
block | NextResponse.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 / instruct | By 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 — the spec-401 form additionally sets WWW-Authenticate: KYA. redirectMode: 'http' sends a plain 302 instead. |
challenge | Treated 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):
| Option | Type | Default | Description |
|---|---|---|---|
ApiKey | string? | — | Dashboard API key (sk_…). Enables policy enforcement. |
ProjectId | string? | — | Project ID in the dashboard. |
BaseUrl | string | https://kya.vouched.id | Checkpoint API base URL. |
OnAgentDetected | DetectedAction | DetectedAction.Log | Action when a detected agent exceeds the confidence threshold. Values: Log, Block, Allow, Redirect, Instruct, Challenge. |
ConfidenceThreshold | double | 70 | Minimum confidence (0–100) to trigger OnAgentDetected. |
EnableSessionTracking | bool | true | Track agent sessions across requests using a canonical session ID. |
EnableSignatureVerification | bool | true | Verify Ed25519 signatures (ChatGPT RFC 9421, KYA-OS agents). |
EnableComposedPolicy | bool | true | Wire in-process composed /policy-compose Cedar evaluation (shadow-first). |
Tier3Action | Tier3Action | Monitor | Engine-default behaviour for Tier-3 UA matches (Monitor / Block / Challenge). |
McpServerUrl | string? | — | MCP server URL used as the redirect target when OnAgentDetected = Redirect. |
FailOpen | bool | true | Allow 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({
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
- Policies — Configure enforcement rules
- Detection in Enforce Mode — How middleware detects agents
- Gateway Enforcement — Alternative: DNS-based enforcement
