Runtime security

How do I detect and block attacks at runtime?

Detect in the path of the action, then deny before the side effect. Four pattern families share a handler and need different detectors. Prefer an in-process check over a scanner that receives the raw body.

7 min read
In short: Detect in the path of the action, then deny before the side effect. Four pattern families share a handler and need different detectors. Prefer an in-process check over a scanner that receives the raw body.

How do I detect and block attacks at runtime?

Detect the signal in the path of the action, then branch on it before the side effect. What is runtime application security? is the definition. The rest of this document is the pattern table and the instrumentation.

A scanner that reports after the query ran is monitoring. A proxy that copies the body to a second hop to classify it is a disclosure. Runtime detection is a function you call, in process, that returns enough to deny.

Runtime attack patterns

Four families show up in live traffic. They share a handler. They don't share a detector. The following table lists each family:

PatternWhat it looks likeSignalBlock
Injection

SQL, XSS, path traversal, template injection, or prompt injection in input or retrieved text

Signatures and parsers on HTTP; a specialist classifier on free-text model input

Reject the request or drop the chunk before execute or provider
Abuse

Credential stuffing, scraping, card testing, bot-driven signups, runaway agent loops

Identity-aware rate limits, bot classification, burst and retry shape

An HTTP 429 or 403 status code on this identity. An IP-only limit is the weaker version

Exfiltration

Protected data leaving through a response, a tool argument, a log, or an embedding

Sensitive-information spans on the string about to leaveRedact or deny at that boundary, in process
Agent manipulation

Indirect instructions in tool output, or a well-formed tool call the user never asked for

Injection on re-entry text; authorization on this tool, these args, this user

Deny inside the tool. Don't treat a clean inbound score as allow

Injection on a form post and prompt injection on a chat box are the same placement (before the next hop) with different detectors. What is API abuse? covers the abuse family. Prevent data exfiltration through AI agents covers the data family. AI agent runtime security covers the agent family.

What to instrument

You need evidence at the handler, not after it.

Identity is the authenticated user, API key, or tenant. IP is a fallback, not the only key. Shared NATs and rotating clients make IP-only limits fail in both directions. Operation is the route, tool name, or job label. A search and a refund must not share one threshold. Payload shape is the parsed fields, not raw bytes only. Broken object-level authorization is an application question; for the HTTP baseline, see API security best practices. Free-text spans are the user messages, retrieved chunks, comments, and email bodies. Scan those. Don't scan opaque IDs.

Record the decision (allow or deny, rule, identity, correlation ID) so you can tune thresholds. A correlationId reconstructs a run. It doesn't change today's allow or deny.

Block without copying the body out

A common design is "send the request to a scanner, then proceed." That copies the body to a vendor so the vendor can tell you not to copy the body. Privacy review treats that vendor as a recipient.

Prefer a library in the process. Parse the request or the tool args in your handler. Call the detector on the strings you already have. Branch. On deny, return an HTTP 403 or 429 status code and don't call the database, the provider, or the outbound API.

Pattern matching, schema validation, and local sensitive-information detection can stay on-box. Shared counters (distributed rate limits) and some specialist models need a network call. That call must send a key and a verdict request, not a second copy of a medical note, unless you have accepted that disclosure.

For more information about the residency version of this argument, see keeping security inspection local.

Instrument in Node.js, Python, and Go

Extract identity and free text, run detectors, deny, then do the work. These handlers are generic. Swap in your own looksLikeInjection, tooMany, and hasSensitive functions.

Node.js:

export async function handle(req: Request, userId: string) {
const body = await req.text();
if (looksLikeInjection(body) || hasSensitive(body)) {
// Generic body. Don't name the rule or echo the matched span.
return new Response("Forbidden", { status: 403 });
}
const limit = await tooMany(userId);
if (limit.exceeded) {
return new Response("Too Many Requests", {
status: 429,
headers: { "retry-after": String(limit.resetSeconds) },
});
}
return runBusinessLogic(body);
}

tooMany returns the reset time as well as the verdict, because a 429 without Retry-After turns a well-behaved client into a hot loop. Keep that counter in Redis or your rate-limit service, not in a module-level Map: a per-instance counter multiplies your intended limit by the number of instances, and it resets on every deploy.

Python (FastAPI):

from fastapi import Depends, FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/v1/action")
async def action(
request: Request,
# From the session, not a query parameter the caller can rotate.
user_id: str = Depends(current_user_id),
) -> dict[str, str]:
body = (await request.body()).decode()
if looks_like_injection(body) or has_sensitive(body):
raise HTTPException(status_code=403, detail="Forbidden")
limit = await too_many(user_id)
if limit.exceeded:
raise HTTPException(
status_code=429,
detail="Too Many Requests",
headers={"retry-after": str(limit.reset_seconds)},
)
return await run_business_logic(body)

Go (net/http):

func action(w http.ResponseWriter, r *http.Request) {
userID := userIDFromSession(r)
// Cap the read. An unbounded io.ReadAll on a request body is its own
// availability bug, and a detector on 200 MB of text is a timeout.
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if looksLikeInjection(body) || hasSensitive(body) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if limit := tooMany(r.Context(), userID); limit.Exceeded {
w.Header().Set("Retry-After", strconv.Itoa(limit.ResetSeconds))
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
runBusinessLogic(w, body)
}

Each handler reads identity from the session, bounds the body, screens the strings it already has, and returns before the work. Nothing here needs a new deployment topology.

On a path with no Request (a tool, an MCP handler, a queue job) the same predicates run on the arguments. There's nothing for a web application firewall (WAF) to inspect. For more information about that placement, see secure MCP server and agent tool calls.

One published SDK on the HTTP handler

The following Node.js protect() call is a web-attack filter, bot detection, and a sliding window on one route. guard() is a different API: no Request, and no bot primitive.

import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/node";
import { createServer } from "node:http";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
shield({ mode: "LIVE" }),
detectBot({ mode: "LIVE", allow: [] }),
slidingWindow({ mode: "LIVE", interval: 60, max: 100 }),
],
});
createServer(async (req, res) => {
const decision = await aj.protect(req);
if (decision.isDenied()) {
res.writeHead(decision.reason.isRateLimit() ? 429 : 403);
res.end();
return;
}
res.writeHead(204);
res.end();
}).listen(3000);

interval is seconds (or a duration string such as "1h"). max: 100 is a hundred hits a minute from the characteristics the adapter already extracts, which is ip.src unless you set characteristics on the client or the rule. Set it to ["userId"] and you have to pass that value on every protect() call, which is the point: a limit keyed on something the caller supplies isn't a limit.

Shield and sensitive-info detection run in process. Bot detection, prompt injection, and shared rate-limit counters need the Arcjet call. That split is what decides how much a network failure costs you on this route.

The same rules exist in the other SDKs with each language's own option names, so port the shape and check the reference rather than assuming the JavaScript field names. In Go the client is arcjet.NewClient, the rule is arcjet.SlidingWindow(arcjet.SlidingWindowOptions{Mode: arcjet.ModeLive, Interval: time.Minute, MaxRequests: 100}), and aj.Protect(ctx, r) returns a decision and an error. In Python the deny check is decision.is_denied() with the rule type on decision.reason_v2.type.

Dry-run first on a busy route. Move to live one rule at a time. For more information about in-code rollout, see enforce security rules at runtime in code. For more information about mapping those controls onto the OWASP GenAI list, see how to implement the OWASP Top 10 for LLM Applications.

Frequently asked questions

How do I detect and block attacks at runtime?

Instrument identity, operation, payload shape, and free-text spans in the handler. Run the detector that matches the pattern (injection, abuse, exfiltration, or agent manipulation). Branch on deny before the database, the model, or the outbound API. Don't copy the body to a second hop to classify it.

What signals should a runtime detector collect?

Authenticated identity (user, API key, or tenant), the operation being performed, parsed fields, and free-text spans. IP is a fallback. A correlation ID reconstructs a run; it doesn't change allow or deny.

Can I detect attacks by sending the request body to a cloud scanner?

You can, but the scanner becomes a recipient of that body. Prefer a library in the process that returns a verdict on strings that you already parsed. Shared counters might still need a network call; that call must not require a second copy of a medical note.

Do WAFs see agent tool calls and queue jobs?

No. Those paths have no HTTP request for a perimeter tool to inspect. Run the same predicates on the function arguments. Bot detection is an HTTP primitive and doesn't apply on guard().

Application security in your code

Protect your application with Arcjet

Get rate limits, bot detection, and attack blocking in your request handlers.