> How to stop abuse and runaway cost on a public LLM route in Nuxt: auth, per-user rate limits, prompt caps, token budgets, Turnstile and spend limits.
>
> Web page: https://balazscsorba.com/blog/llm-endpoint-abuse-denial-of-wallet · Language: English · Also available in: [Deutsch](https://balazscsorba.com/de/blog/llm-endpoint-abuse-denial-of-wallet.md) · [Magyar](https://balazscsorba.com/hu/blog/llm-endpoint-abuse-denial-of-wallet.md)
> Author: Balázs Csorba · Published: 2026-10-16 · Keywords: denial of wallet llm, owasp llm10 unbounded consumption, rate limit llm api nuxt, nuxt security rate limiter, protect openai api key frontend, llm token budget per user, limit llm cost per user, nitro api rate limiting, cloudflare turnstile nuxt, anthropic spend limit, openai hard spend limit, llm prompt size limit

[Blog](https://balazscsorba.com/blog)/Web engineering

# Denial of wallet: protecting public LLM endpoints in a Nuxt app

How to stop abuse and runaway cost on a public LLM route in Nuxt: auth, per-user rate limits, prompt caps, token budgets, Turnstile and spend limits.

[Balázs Csorba](https://balazscsorba.com/about)·October 16, 2026·14 min read

-   Nuxt
-   LLM security
-   Rate limiting
-   OWASP
-   Cost control

![Requests to a Nuxt chat endpoint pass through bot checks, sessions, rate limits, prompt caps and a token budget before reaching the model API.](https://balazscsorba.com/images/blog/llm-endpoint-abuse-denial-of-wallet/cover.webp?v=87cca578cd)

## Key takeaways

-   A public LLM route is a pay-per-use API with your card behind it; OWASP files the risk under LLM10:2025 Unbounded Consumption.
-   Put a session in front of the model call and never expose a generic proxy: the server picks the model, the system prompt and max\_tokens.
-   Limit requests per user and per IP in a shared store, and limit tokens separately, because one request can cost a thousand times another.
-   Reserve a token budget before the call and settle it with the real usage afterwards, using an atomic counter.
-   Provider spend limits are the last fuse, not the control: they switch the feature off for every user at once and are not enforced instantly.

On this page

1.  [What is denial of wallet?](https://balazscsorba.com/#what-is-denial-of-wallet)
2.  [Which abuse patterns hit public LLM routes?](https://balazscsorba.com/#abuse-patterns)
3.  [What does a layered defence look like?](https://balazscsorba.com/#layered-defence)
4.  [Why does authentication come first?](https://balazscsorba.com/#authentication-first)
5.  [How should you rate-limit per user and per IP?](https://balazscsorba.com/#rate-limits)
6.  [How do you cap prompt size and output?](https://balazscsorba.com/#input-limits)
7.  [How do you enforce token budgets per user?](https://balazscsorba.com/#token-budgets)
8.  [Where do CAPTCHAs and Turnstile fit?](https://balazscsorba.com/#bot-protection)
9.  [What do provider spend limits catch?](https://balazscsorba.com/#provider-spend-limits)
10.  [What should you monitor and alert on?](https://balazscsorba.com/#monitoring)
11.  [A checklist for a public LLM route](https://balazscsorba.com/#checklist)
12.  [Sources](https://balazscsorba.com/#sources)

Listen to this article

0:000:00

An LLM feature on a public website is a pay-per-use API with your payment details behind it. A contact assistant, a product finder or a "summarize this page" button all end in a server route that forwards text to a model and pays per token. If that route answers anyone, any number of times, with any input, the question is not whether someone will use it as a free model API, only when.

This article is the cost and abuse side of shipping LLM features in Nuxt. It maps the OWASP LLM10:2025 Unbounded Consumption risk to concrete layers in a Nuxt and Nitro app: sessions, request and token limits, prompt-size caps, per-user budgets, Cloudflare Turnstile and the spend limits that OpenAI and Anthropic offer, with code you can adapt.

## What is denial of wallet?

OWASP describes [Unbounded Consumption](https://genai.owasp.org/llmrisk/llm102025-unbounded-consumption/) as an LLM application allowing users to run "excessive and uncontrolled inferences", which leads to denial of service, economic losses, model theft and degraded service. Denial of wallet is the cost-focused variant: an attacker generates excessive operations to exploit the pay-per-use model of cloud AI services, causing unsustainable costs for the provider. Nothing has to go down for the attack to succeed; the bill is the damage.

LLM routes are more exposed than ordinary API routes for two reasons. Cost per request is variable and large: a request that sends a long conversation and asks for a long answer can cost orders of magnitude more than a one-line question. And the output is directly useful to the attacker, so an open chat endpoint is worth scripting even when it has nothing to do with your product.

## Which abuse patterns hit public LLM routes?

The OWASP entry lists seven examples. Mapped onto a typical Nuxt feature, they reduce to a handful of patterns, each with a primary control:

Pattern

What it looks like

Primary control

Free-riding proxy (denial of wallet)

Scripts use your route as a free model API; prompts unrelated to your product

Session, fixed system prompt, per-user token budget

Repeated requests

High request rate from a few accounts or IPs

Rate limits at the edge and per user

Variable-length input flood, continuous input overflow

Huge bodies, pasted documents, ever-growing histories

Body size limit, schema validation, token count cap

Resource-intensive queries

Requests for very long outputs, tool loops, heavy reasoning

Server-side max\_tokens, step limits, timeouts

Model extraction and functional replication

Systematic queries that harvest outputs as training data

Volume caps, no logprobs, anomaly monitoring

There is a sixth pattern OWASP does not name but every team meets: self-inflicted consumption. A retry loop without a cap, an agent that keeps calling tools, or a client bug that resends the full history on every keystroke burns budget exactly like an attacker. The same limits catch it.

## What does a layered defence look like?

No single control is enough, because each one measures something different. A rate limiter counts requests, a size limit counts bytes, a budget counts tokens and a spend limit counts money. I order them from cheapest to most expensive to evaluate, so that rejected traffic costs as little as possible:

Cheap checks first: a request rejected at the edge costs nothing, one rejected by the provider spend limit has already taken the feature down.

## Why does authentication come first?

Every other limit needs an identity to attach to. Without a session, the only key you have is the IP address, and that is weak. With [nuxt-auth-utils](https://github.com/atinux/nuxt-auth-utils), `requireUserSession(event)` returns the session or responds with 401, so the first line of the handler decides whether the model call can happen at all. For a feature that must work before login, such as a product finder, issue a lightweight anonymous session after a bot check and give it a much smaller budget than a logged-in customer.

The second rule is to never ship a generic proxy. If the client can choose the model, the system prompt, `max_tokens` or tools, your endpoint is an open model API with extra steps. The client sends a message and maybe a conversation id; the server decides everything else. That also keeps the API key on the server, as described in [shipping LLM features in Nuxt](https://balazscsorba.com/blog/nuxt-llm-features-ai-sdk-streaming).

## How should you rate-limit per user and per IP?

The [nuxt-security](https://nuxt-security.vercel.app/middleware/rate-limiter) module ships a rate limiter that is enabled globally by default: 150 requests per 300,000 ms per IP, stored in an LRU cache, answering 429 Too Many Requests when exceeded. It can be tuned per route through `routeRules`, and its storage can be any [unstorage](https://unstorage.unjs.io/) driver. Its own documentation is honest about the limits: the built-in rate limiter "is not suitable for complex production applications", and it recommends solutions on the infrastructure layer.

For an LLM route that means three adjustments. Use a shared store, because an in-memory LRU cache is per instance and resets on every deploy. Set a much tighter limit on the model route than on the rest of the site. And resolve the client IP correctly: h3's `getRequestIP` only reads `X-Forwarded-For` when you opt in, because the leftmost entry is client input; behind Cloudflare, nuxt-security's `ipHeader` option can point at `cf-connecting-ip` instead.

```
// nuxt.config.ts (illustrative)
export default defineNuxtConfig({
  modules: ['nuxt-security', 'nuxt-auth-utils'],
  security: {
    rateLimiter: {
      driver: { name: 'redis', options: { url: process.env.REDIS_URL } },
      ipHeader: 'cf-connecting-ip', // only if Cloudflare sits in front
    },
  },
  routeRules: {
    '/api/chat': {
      security: {
        rateLimiter: { tokensPerInterval: 20, interval: 60_000 },
        requestSizeLimiter: { maxRequestSizeInBytes: 32_000 },
      },
    },
  },
})
```

This limiter is keyed by IP. The per-user limit belongs in the handler, after `requireUserSession`, keyed by the user id. Which algorithm you use there matters less than using one consistently; the trade-offs as [Upstash documents them](https://upstash.com/docs/redis/sdks/ratelimit-ts/algorithms):

Algorithm

Strength

Weakness

Fixed window

Very cheap in storage and computation

Bursts leak through at window boundaries; stampedes when a new window starts

Sliding window

Fixes the boundary problem

More storage and computation; an approximation

Token bucket

Smooths bursts; allows a higher initial burst

More state to reason about

Anthropic's own API uses a token bucket, continuously replenished up to the limit rather than reset at fixed intervals, which is a reasonable default to copy for a chat feature where users type in bursts.

## How do you cap prompt size and output?

A request limit says nothing about what each request costs. Cap the input in three steps. First bytes: nuxt-security's request size limiter answers 413 Payload Too Large above `maxRequestSizeInBytes`, which defaults to 2,000,000; a chat message needs a few kilobytes. Then shape: validate the body with `readValidatedBody` and a schema that limits message length. Then tokens: count before you call.

The biggest hole is usually conversation history. If the client sends the whole conversation on every turn, an attacker can send a fabricated history of any length. Keep the history on the server, keyed by conversation id, and trim it to a fixed number of turns or tokens. Anthropic's [token counting endpoint](https://platform.claude.com/docs/en/build-with-claude/token-counting) is free, has its own rate limit separate from message creation, and returns an estimate of input tokens, which is enough for a cap.

Output is capped by `max_tokens`, set on the server. Note what it does and does not do: for Anthropic's rate limits, output tokens are counted as they are produced and `max_tokens` does not factor into the output-tokens-per-minute calculation. It is a cost ceiling per request, not a rate control, so you still need the budget in the next section.

```
// server/api/chat.post.ts (illustrative)
import { z } from 'zod'

const Body = z.object({
  conversationId: z.string().uuid(),
  message: z.string().min(1).max(4000),
})
const MAX_INPUT = 12_000   // tokens, after history trimming
const MAX_OUTPUT = 800     // the server decides, never the client

export default defineEventHandler(async (event) => {
  const { user } = await requireUserSession(event)          // 401 without a session
  const body = await readValidatedBody(event, Body.parse)    // 400 on bad or long input

  const history = await loadHistory(user.id, body.conversationId, { maxTurns: 10 })
  const messages = [...history, { role: 'user', content: body.message }]

  const { input_tokens } = await anthropic.messages.countTokens({ model: MODEL, system: SYSTEM, messages })
  if (input_tokens > MAX_INPUT) throw createError({ statusCode: 413, statusMessage: 'Conversation too long' })

  const reserved = input_tokens + MAX_OUTPUT
  const key = await reserveTokens(user.id, reserved)          // null when the daily budget is spent
  if (!key) throw createError({ statusCode: 429, statusMessage: 'Daily limit reached' })

  let used = 0
  try {
    const res = await anthropic.messages.create({ model: MODEL, system: SYSTEM, messages, max_tokens: MAX_OUTPUT })
    const u = res.usage
    used = u.input_tokens + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + u.output_tokens
    await saveTurn(user.id, body.conversationId, body.message, res.content)
    return res.content
  } finally {
    await settleTokens(key, reserved, used)                   // refund the unused part
  }
})
```

## How do you enforce token budgets per user?

A budget is the control that actually bounds cost: tokens per user per day, or per anonymous session per hour. The pattern I use is reserve, then settle. Before the call, reserve the worst case, which is the counted input plus `max_tokens`. If the reservation would exceed the budget, reject with 429. After the call, settle with the real usage from the response and refund the difference. Concurrent requests from the same user then cannot overspend, because each one has already claimed its share.

The reservation makes the budget safe under concurrency; settling keeps it accurate.

The counter must be atomic. A `getItem` followed by `setItem` through Nitro storage is a read-modify-write, and two parallel requests can both read the old value. Redis `INCRBY` returns the new total in one step, so the check and the reservation cannot race:

```
// server/utils/budget.ts (illustrative, ioredis)
const DAILY_TOKENS = 200_000

export async function reserveTokens(userId: string, n: number) {
  const key = `llm:budget:${userId}:${new Date().toISOString().slice(0, 10)}`
  const total = await redis.incrby(key, n)
  if (total === n) await redis.expire(key, 60 * 60 * 48)
  if (total > DAILY_TOKENS) { await redis.decrby(key, n); return null }
  return key
}

export async function settleTokens(key: string, reserved: number, used: number) {
  if (reserved > used) await redis.decrby(key, reserved - used)
}
```

Count all input tokens when you settle. With prompt caching, Anthropic's `input_tokens` only covers tokens after the last cache breakpoint; the total input is `cache_read_input_tokens + cache_creation_input_tokens + input_tokens`. Whether you weigh cached tokens at their lower price is a product decision; a plain token budget is easier to explain to users. For the cost side of caching and routing, see [prompt caching and model routing](https://balazscsorba.com/blog/llm-cost-latency-prompt-caching-routing).

## Where do CAPTCHAs and Turnstile fit?

A bot check raises the price of automation; it does not cap cost. I use it at the point where an identity is created, for example before issuing an anonymous chat session, not on every message. With [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/), server-side validation through the Siteverify API is mandatory, because the client widget alone protects nothing: tokens can be forged, each token is valid for 300 seconds, and each can be validated only once, with a replay rejected as `timeout-or-duplicate`.

```
// server/api/session/anonymous.post.ts (illustrative)
export default defineEventHandler(async (event) => {
  const { token } = await readBody<{ token: string }>(event)
  const r = await $fetch<{ success: boolean }>('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
    method: 'POST',
    body: { secret: useRuntimeConfig().turnstileSecret, response: token, remoteip: getRequestIP(event) },
  })
  if (!r.success) throw createError({ statusCode: 403 })
  await setUserSession(event, { user: { id: crypto.randomUUID(), tier: 'anonymous' } })
  return { ok: true }
})
```

**A bot check is not a budget**

Humans can be paid to solve challenges, and a valid session can still send expensive requests all day. Treat Turnstile as a speed bump in front of session creation, and let the per-session token budget do the capping.

## What do provider spend limits catch?

Both large providers let you cap monthly spend, and you should set a cap below the amount that would hurt. But read the behaviour: these limits stop all traffic in their scope, for every user, until the month resets or you raise them.

Aspect

Claude API (Anthropic)

OpenAI API

Limit you set

Spend limit per organization, below the tier cap; custom spend and rate limits per workspace

Hard spend limit per organization or per project; spend alerts that only notify

Response when reached

HTTP 400 `invalid_request_error`, "You have reached your specified API usage limits"

HTTP 429 with `organization_spend_limit_exceeded` or `project_spend_limit_exceeded`

Built-in ceiling

Monthly tier cap: Start 500, Build 1,000, Scale 200,000 USD; 429 `enforced_spend_limit_reached` without retry-after

Monthly usage limits per usage tier

Caveat

Workspace limits cannot be set on the default workspace

"Enforcement is not instantaneous"; recorded spend can slightly exceed the limit

Two practical consequences. Give each public feature its own Anthropic workspace or OpenAI project with its own key and limit, so a runaway chatbot cannot stop your internal tools. And handle the spend-limit responses explicitly: the SDKs retry 429s automatically, and Anthropic notes that retries fail until access resumes. Map them to a friendly "temporarily unavailable" state and page someone.

## What should you monitor and alert on?

OWASP's mitigations include comprehensive logging with anomaly detection and graceful degradation. In a Nuxt app that translates into a few numbers per feature, logged with the user id and the conversation id:

-   **Tokens per user per day**, with an alert when the top user is far above the median. Free-riders show up here first.
-   **Rejections by layer**: 401, 413 and 429 counts per hour. A sudden rise in 429s from the budget means someone is probing.
-   **Input tokens per request**, as a distribution, not an average. A long tail means the history or size caps are too loose.
-   **Daily cost per feature against a baseline**, with a provider spend alert set well below the hard limit.

Degrade before you shut down. When spend crosses a threshold, switch anonymous users off first, then move logged-in users to a smaller model, and keep the feature alive for paying customers. That is a far better failure mode than the provider limit switching everything off at once. For tracing the calls behind these numbers, see [observability for LLM agents](https://balazscsorba.com/blog/agent-observability-opentelemetry).

## A checklist for a public LLM route

1.  The API key lives only in server runtime config; the client never sees it.
2.  The route requires a session; anonymous sessions are issued only after a server-validated bot check.
3.  Model, system prompt, tools and `max_tokens` are fixed on the server.
4.  Request rate limits per IP at the edge and per user in the handler, in a shared store.
5.  Body size limit, schema validation, server-side history and a token count cap before the call.
6.  A daily token budget per user with atomic reserve and settle.
7.  Bounded retries, tool steps and timeouts, so your own code cannot loop.
8.  A separate provider workspace or project per feature, with a spend alert and a hard limit.
9.  Alerts on per-user token outliers, rejection spikes and daily cost, plus a degradation plan.

Cost abuse is one half of the risk; the other is what the model does with hostile input. For that, see [prompt injection defense](https://balazscsorba.com/blog/prompt-injection-lethal-trifecta-patterns).

## Sources

1.  [OWASP GenAI Security Project: LLM10:2025 Unbounded Consumption](https://genai.owasp.org/llmrisk/llm102025-unbounded-consumption/)
2.  [Nuxt Security: Rate Limiter middleware](https://nuxt-security.vercel.app/middleware/rate-limiter)
3.  [Nuxt Security: Request Size Limiter middleware](https://nuxt-security.vercel.app/middleware/request-size-limiter)
4.  [nuxt-auth-utils: README (GitHub)](https://github.com/atinux/nuxt-auth-utils)
5.  [h3: request utilities (getRequestIP, readValidatedBody)](https://h3.dev/utils/request)
6.  [Upstash: Ratelimiting algorithms](https://upstash.com/docs/redis/sdks/ratelimit-ts/algorithms)
7.  [Cloudflare Turnstile: Validate the token](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/)
8.  [Claude Platform Docs: Rate limits and spend limits](https://platform.claude.com/docs/en/api/rate-limits)
9.  [Claude Platform Docs: Token counting](https://platform.claude.com/docs/en/build-with-claude/token-counting)
10.  [OpenAI API Docs: Spend limits](https://developers.openai.com/api/docs/guides/spend-limits)
11.  [OpenAI API Docs: Rate limits](https://platform.openai.com/docs/guides/rate-limits)

## Frequently asked questions

What is denial of wallet?

Denial of wallet is an attack on a pay-per-use service where the goal is cost rather than downtime. OWASP lists it as an example of LLM10:2025 Unbounded Consumption: an attacker generates so many operations that the bill for the provider becomes unsustainable.

Is the nuxt-security rate limiter enough for an LLM endpoint?

It is a good first layer, but its own documentation says the built-in rate limiter is not suitable for complex production applications. It counts requests, not tokens, and its default LRU cache is per server instance. Pair it with an edge limit and a per-user token budget in a shared store such as Redis.

Should I rate-limit by IP or by user?

Both. IP limits protect the routes that work before login and blunt simple floods. Per-user limits are the ones that matter for cost, because many legitimate users can share an IP and one attacker can rotate through many.

How do I limit the length of prompts?

Cap the request body size, validate the message length with a schema, keep the conversation history on the server instead of accepting it from the client, and check the token count before calling the model. Set max\_tokens on the server to cap the output.

Do provider spend limits protect me from abuse?

Only as a last resort. OpenAI hard limits and Anthropic spend limits stop all traffic on the organization, project or workspace once reached, so an attacker who hits them takes the feature down for everyone. OpenAI also notes that enforcement is not instantaneous and spend can slightly exceed the limit.

Written by Balázs Csorba

Senior fullstack & AI engineer in Styria, Austria – 10+ years of Vue, Nuxt, Node.js and PHP, now building tooling for AI agents.

[Vue.js & Nuxt development →](https://balazscsorba.com/expertise/vue-nuxt-developer)[About me →](https://balazscsorba.com/about)

## More articles

-   [Multilingual SEO with Nuxt i18n: URL strategy, hreflang, canonicals and sitemaps](https://balazscsorba.com/blog/nuxt-i18n-multilingual-seo-hreflang)
-   [llms.txt vs Markdown content negotiation: what agents actually fetch](https://balazscsorba.com/blog/llms-txt-vs-markdown-content-negotiation)
-   [Building a multiplayer 3D sailing game with plain three.js](https://balazscsorba.com/blog/multiplayer-sailing-game-threejs)
-   [Charging on EPEX Austria prices: what my Home Assistant app saves](https://balazscsorba.com/blog/home-assistant-ev-charging-energy-manager)

## Sounds like what you need?

Tell me about your project or role – I’d love to hear from you.

[Get in touch](mailto:contact@balazscsorba.com) [Connect on LinkedIn](https://www.linkedin.com/in/balazs-csorba)
