Skip to content

Arcjet Guards

Guards apply Arcjet security rules inside AI agent tool calls and anywhere else you process untrusted input. Pass inputs directly, get a decision back. No Request object needed.

They are for the places HTTP middleware can’t reach: inside tool handlers, between agent steps, in queue consumers, and across agentic pipelines that fan out across many services.

Stop hidden instructions from hijacking your agent

Section titled “Stop hidden instructions from hijacking your agent”

A webpage your agent fetched contains hidden instructions telling it to email your customer list to an attacker. Guards detects the prompt injection before that content re-enters the model’s context.

A runaway agent loop burns $8,000 in model spend overnight. Guards enforces per-user token budgets inside the loop itself, denying calls once the bucket is empty.

A background job ships support tickets to a third-party LLM for summarization. Guards blocks personally identifiable information (emails, card numbers, etc.) before the message leaves your system.

Arcjet has two entrypoints. Pick the one that matches the surface you need to secure:

Protect (request SDKs)Guard (@arcjet/guard, arcjet.guard)
Designed forHTTP route handlers, API endpointsAI tool calls, MCP servers, queue workers, background jobs
Request objectRequired (protect(request, ...))Not needed — pass inputs directly
Rule bindingRules configured once, input via protect() kwargs / optionsRules configured as classes, called with input per invocation
Rate limit keyIP or characteristics dictExplicit key string at call time
Rate Limiting
Prompt Injection Detection
Sensitive Information Detection
Custom Rules
Bot Protection
Shield WAF
Email Validation
Request Filters
IP Analysis

A single application can use both — request-based protection on API routes and guards inside tool handlers / queue workers. Both share the same Arcjet account and dashboard.

Use a guard wherever an Arcjet decision is needed but no HTTP request exists:

  • AI agent tool calls — inside each tool/skill function so denied actions never reach the LLM call site.
  • MCP servers — stdio or SSE-based MCP servers don’t receive HTTP requests from the LLM client. Guard each tool handler with a hardcoded label.
  • Queue consumers / background jobs — protect per-job budgets and scan job payloads for prompt injection or PII before processing.
  • Agentic pipelines — fan-out workflows (e.g. one user prompt → many downstream tool calls) need per-step enforcement that survives the boundary between services.
  • Re-checking content returned to the model — guard prompt injection on tool results when the tool fetches untrusted content (web pages, third party APIs).

If the protection site already has a Request object — Express, Next.js route handlers, FastAPI handlers, Lambda HTTP events — use the request-based SDKs instead. Request-based SDKs include extra signals (IP analysis, headers, bot fingerprinting) that guards do not.

Install the Arcjet skill into your AI coding agent. It will detect your language, install the package, configure rules, and wire up guard() calls inline in your tool handlers.

Supported languages: JavaScript / TypeScript (@arcjet/guard >= 1.4.0) and Python (arcjet >= 0.7.0).

Terminal window
npx skills add arcjet/skills

Then describe what you want to protect — for example: “rate limit my tool calls per user and block prompt injection” or “secure my MCP server” or “protect my queue worker”.

If you’d rather wire it up by hand:

Terminal window
npm install @arcjet/guard

Requires Node.js / Bun / Deno and @arcjet/guard >= 1.4.0.

Set ARCJET_KEY in your environment — retrieve it from the Arcjet dashboard, the Arcjet CLI, or the MCP server.

The guard client holds a persistent connection to the Arcjet decision service. Create it once at module scope and reuse it for every call:

import { launchArcjet } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });

Rate limit state is tracked server-side by the rule’s bucket name, so recreating rules per call won’t break counting. However, you should still declare rules at module scope:

  • The per-rule result accessors (userLimit.deniedResult(decision) in JS, user_limit.denied_result(decision) in Python) only work when you have a stable reference.
  • It avoids unnecessary object allocation on every invocation.
  • It keeps rule configuration visible and centralized.
import { tokenBucket, detectPromptInjection } from "@arcjet/guard";
const userLimit = tokenBucket({
refillRate: 10,
intervalSeconds: 60,
maxTokens: 100,
bucket: "user-tools", // name explicitly to avoid collisions across rules
});
const piRule = detectPromptInjection();

guard() at the operation, with a hardcoded label

Section titled “guard() at the operation, with a hardcoded label”

Call guard() wherever you already know exactly what operation is happening — usually inside the specific tool/task function. The label should be a hardcoded string literal ("tools.get_weather", not `tools.${name}`). Hardcoded labels stay greppable and the dashboard groups by them; interpolation produces a sea of distinct-looking calls.

Pass metadata whenever you have useful auditing context (userId, requestId, etc.) — it appears in the dashboard alongside the decision and makes debugging much easier.

async function getWeather(city: string, userId: string) {
const decision = await arcjet.guard({
label: "tools.get_weather",
rules: [userLimit({ key: userId, requested: 1 })],
metadata: { userId },
});
if (decision.conclusion === "DENY") {
throw new Error(`Blocked: ${decision.reason}`);
}
// ... do the work
}

Every guard rule accepts mode ("LIVE" enforces, "DRY_RUN" logs only), label (an observability label that appears in the dashboard), and metadata (arbitrary key-value context).

Three algorithms are available — pick the one that matches the cost model of the operation you’re protecting:

  • Token bucket — variable cost per call (e.g. LLM token spend). Bursts up to maxTokens, refills at refillRate per intervalSeconds.
  • Fixed window — hard cap per window (e.g. 1000 tool calls per hour). Resets at window boundaries.
  • Sliding window — smooth limit without boundary bursts (e.g. 500 requests per rolling 60 seconds).

All three require a key and a bucket name at call time. Without a key, limits would be global across all callers.

import { tokenBucket, fixedWindow, slidingWindow } from "@arcjet/guard";
const userLimit = tokenBucket({
refillRate: 100,
intervalSeconds: 60,
maxTokens: 1000,
bucket: "user-tools",
});
const teamLimit = fixedWindow({
maxRequests: 1000,
windowSeconds: 3600,
bucket: "team-api",
});
const apiLimit = slidingWindow({
maxRequests: 500,
intervalSeconds: 60,
bucket: "public-api",
});
// At call time:
const decision = await arcjet.guard({
label: "tools.weather",
rules: [userLimit({ key: userId, requested: 5 })],
});

Picking a key when there’s no user: some call sites have no per-user context — a stdio MCP server where the client is the only caller, or a single-tenant worker. Don’t fake it with an empty string. Use an identifier that matches the scope of the limit (deployment name, hostname, the MCP session id if exposed) and add a comment if it’s deliberately global.

Use on any untrusted text before it reaches a model or is used as a tool argument. Also useful on tool call results when the tool fetches content from untrusted sources (web pages, third-party APIs).

import { detectPromptInjection } from "@arcjet/guard";
const piRule = detectPromptInjection();
const decision = await arcjet.guard({
label: "tools.weather",
rules: [piRule(userMessage)],
});

Detects PII (email addresses, phone numbers, IP addresses, credit card numbers) in text locally via WASM — the raw text never leaves the SDK, which matters for compliance. Built-in entity types: EMAIL, PHONE_NUMBER, IP_ADDRESS, CREDIT_CARD_NUMBER.

import { localDetectSensitiveInfo } from "@arcjet/guard";
const sensitive = localDetectSensitiveInfo({
deny: ["EMAIL", "CREDIT_CARD_NUMBER"],
});
const decision = await arcjet.guard({
label: "tools.send_email",
rules: [sensitive(userInput)],
});

allow and deny are mutually exclusive — pass only one.

Custom rules let you plug in arbitrary local evaluation logic with typed config, input, and result data. They run inside the SDK — no data leaves your environment.

See the @arcjet/guard README for the full TypeScript API.

decision.conclusion is either "ALLOW" or "DENY". Always check before proceeding. For useful error messages, branch on which rule denied — not just on DENY. Each rule defined at module scope exposes typed result accessors that return rule-specific info (e.g. remaining tokens, reset time) for actionable error messages.

if (decision.conclusion === "DENY") {
const rateLimited = userLimit.deniedResult(decision);
if (rateLimited) {
throw new Error(
`Rate limited — retry in ${rateLimited.resetInSeconds}s`,
);
}
if (decision.reason.isPromptInjection()) {
throw new Error("Input flagged as prompt injection");
}
throw new Error("Blocked");
}
// hasError() means a rule errored or the server reported diagnostics.
// The SDK failed open — log it but don't block the caller.
if (decision.hasError()) {
console.warn("Arcjet rule error");
}

Every guard rule accepts a mode parameter. Use "DRY_RUN" to evaluate rules without blocking — decisions are logged in the Arcjet dashboard but the conclusion is always "ALLOW". This lets you tune rule configuration in production before promoting to "LIVE".

const userLimit = tokenBucket({
refillRate: 10,
intervalSeconds: 60,
maxTokens: 100,
bucket: "user-tools",
mode: "DRY_RUN",
});

MCP servers don’t receive HTTP requests — they’re invoked by an MCP client over stdio or SSE. Use a guard at the tool handler with a hardcoded label and a key that matches the scope of your server (single-user, single-tenant, or session-based).

server.tool("query_database", async ({ query, sessionId }) => {
const decision = await arcjet.guard({
label: "mcp.query_database",
rules: [
queryLimit({ key: sessionId ?? "default", requested: 1 }),
piRule(query),
],
metadata: { sessionId },
});
if (decision.conclusion === "DENY") {
return { content: [{ type: "text", text: "Blocked by Arcjet" }] };
}
// ... execute the query
});

Prompt injection can arrive through tool results — a fetch tool retrieves a page containing hidden instructions, which then re-enter the model context. Guard the result as well as the input:

async function fetchTool({ url }: { url: string }, userId: string) {
const content = await fetch(url).then((r) => r.text());
const decision = await arcjet.guard({
label: "tools.fetch",
metadata: { userId },
rules: [piRule(content)],
});
if (decision.conclusion === "DENY") {
// Return a safe placeholder rather than the injected content
return { content: "[Content blocked: prompt injection detected]" };
}
return { content };
}

Use the sync variant of the guard client when running inside a sync framework (Celery, Flask jobs). Pass the user ID from the job payload as the rate-limit key:

// Bull / BullMQ worker example
worker.process(async (job) => {
const decision = await arcjet.guard({
label: "queue.summarize",
rules: [
userTaskLimit({ key: job.data.userId, requested: 1 }),
piRule(job.data.input),
],
metadata: { userId: job.data.userId, jobId: job.id },
});
if (decision.conclusion === "DENY") {
throw new Error("Blocked by Arcjet");
}
// ... process the job
});
  • Hardcode the label string. Use "tools.get_weather", not `tools.${name}`. Hardcoded labels stay greppable and the dashboard groups by them.
  • Pass metadata for auditing. userId, sessionId, requestId all show up alongside the decision in the dashboard.
  • Always set bucket on rate limit rules. Default buckets share counters across all rules of that type.
  • Always pass a key to rate limit rules. Without it, limits are global across all callers.
  • Branch on the denied result, not just DENY. Specific error messages help callers retry intelligently (e.g. “rate limited — retry in 12s” vs a generic “blocked”).
  • Fail open on errors. decision.hasError() (JS) / decision.has_error() (Python) means a rule errored but the SDK didn’t block. Log it; don’t deny.