Skip to content
TutorialSecurity & OWASP

API security for AI features: rate limits, prompt injection and error handling

An AI route is an API route that spends money on every request and talks to a system you do not control. Here is one hardened step by step, each step tied to the OWASP risk it answers.

vuejs.school team11 Aug 20267 min readNuxt 4.5 · ai 7.0 · nuxt-security 2.6

Adding a chat assistant to a Nuxt app takes an afternoon. Making it safe to leave running takes a little longer, because an AI route has every problem an ordinary API route has, plus three of its own: each request costs money, the model can be talked into things, and whatever it writes back is text you did not write.

This tutorial hardens one route, a support assistant that answers questions about a customer's orders, using Nuxt 4.5, AI SDK 7 (ai 7.0) and nuxt-security 2.6. It assumes your app already signs users in. Server code lives in server/ at the project root.

The OWASP risks an AI route has to handle

The OWASP Top 10:2025 added a category that fits AI routes well: A10 Mishandling of Exceptional Conditions. OWASP describes it as an application that fails to prevent an unusual situation, fails to notice it, or responds badly afterwards. Its advice is concrete: catch errors where they happen, fail closed (undo or refuse rather than carry on half-done), and put limits on everything, because nothing should be limitless. Unlimited resources lead to denial of service and, in OWASP's words, extraordinary cloud bills.

The OWASP Top 10 for LLM Applications 2025, from the OWASP GenAI Security Project, covers the model-specific side. Five of its entries shape the steps below:

  • LLM01 Prompt Injection: input that changes what the model does. Steps 4 and 5 limit what a fooled model can do.
  • LLM02 Sensitive Information Disclosure: private data leaking through the model's output. Step 4 keeps it out of answers.
  • LLM05 Improper Output Handling: using the model's answer without checking it. Step 5 renders it as text.
  • LLM06 Excessive Agency: a model that can do more than it should. Step 4 scopes its tool.
  • LLM10 Unbounded Consumption: letting users run excessive, uncontrolled model calls. Steps 1 to 3 set the limits.

Step 1: keep the key on the server and limit each IP

The provider key goes in private runtime config, filled from NUXT_OPENAI_API_KEY. Never give it a NUXT_PUBLIC_ name and never call the provider from the browser, because anything the browser holds, a visitor can copy. To check, run grep -rlF "$NUXT_OPENAI_API_KEY" .output/public after a build, in a shell where that variable holds your key. It should print nothing. That covers the built files only: Nuxt writes public runtime config into each page as it renders it, so open a page's source and search for the key too.

The same config file sets the first limit: nuxt-security can rate limit one route by IP.

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-security'],
  runtimeConfig: {
    openaiApiKey: '',
  },
  routeRules: {
    '/api/assistant': {
      security: {
        rateLimiter: { tokensPerInterval: 10, interval: 60_000, headers: true },
      },
    },
  },
})

That sets a limit of ten requests a minute per IP address (interval is in milliseconds), after which the module answers 429 Too Many Requests. headers: true adds x-ratelimit-* headers that tell the client how many requests remain.

Two details from the module's source matter once you deploy. The default store is an in-memory LRU cache, so each server instance counts on its own. And the client IP is read with X-Forwarded-For trusted, a header any client can send. If your app sits behind a proxy or CDN that sets its own client IP header, name it in the ipHeader option so the limit cannot be dodged by inventing addresses.

Step 2: limit each user, not just each IP

IP limits stop scripts. They do not stop one signed-in user with a fast connection, and they punish an office full of people behind one address. Count per user as well, and keep a daily token budget, since tokens are what you pay for:

server/utils/ai-budget.ts
const MAX_PER_MINUTE = 5
const MAX_TOKENS_PER_DAY = 50_000
const storage = () => useStorage('ai-usage')
const today = () => new Date().toISOString().slice(0, 10)

export async function checkBudget(userId: string) {
  const minuteKey = `req:${userId}:${Math.floor(Date.now() / 60_000)}`
  const requests = (await storage().getItem<number>(minuteKey)) ?? 0
  const tokens = (await storage().getItem<number>(`tok:${userId}:${today()}`)) ?? 0
  if (requests >= MAX_PER_MINUTE || tokens >= MAX_TOKENS_PER_DAY) {
    throw createError({ statusCode: 429, statusMessage: 'Too many requests' })
  }
  await storage().setItem(minuteKey, requests + 1)
}

export async function recordTokens(userId: string, used: number) {
  const key = `tok:${userId}:${today()}`
  await storage().setItem(key, ((await storage().getItem<number>(key)) ?? 0) + used)
}

Nitro's useStorage keeps this in memory unless you mount the ai-usage prefix on Redis or your platform's key-value store in nitro.storage, which you need once there is more than one server. The counters are not atomic, which is fine for a small app. Notice what happens when the store is down: getItem throws, the request fails and the model is never called. That is failing closed, which is what A10 asks for.

Step 3: cap tokens, steps, time and input

Unbounded consumption has more doors than request count. A single request can carry a huge conversation, ask for a very long answer, or start a tool loop. Close each one in the route:

server/api/assistant.post.ts
import { createOpenAI } from '@ai-sdk/openai'
import { convertToModelMessages, createUIMessageStreamResponse, isStepCount, safeValidateUIMessages, streamText, toUIMessageStream } from 'ai'
import { z } from 'zod'

const Body = z.object({ messages: z.array(z.unknown()).min(1).max(20) })

export default defineEventHandler(async (event) => {
  const userId = event.context.user?.id
  if (typeof userId !== 'string') throw createError({ statusCode: 401, statusMessage: 'Sign in first' })
  await checkBudget(userId)

  const body = await readValidatedBody(event, Body.parse)
  const parsed = await safeValidateUIMessages({ messages: body.messages })
  if (!parsed.success || JSON.stringify(body.messages).length > 20_000) {
    throw createError({ statusCode: 400, statusMessage: 'Invalid conversation' })
  }

  const result = streamText({
    model: createOpenAI({ apiKey: useRuntimeConfig(event).openaiApiKey })('gpt-5.4-mini'),
    instructions: 'You help signed-in customers with their own orders. You cannot change orders.',
    messages: await convertToModelMessages(parsed.data),
    tools: orderTools(userId),
    stopWhen: isStepCount(3),
    maxOutputTokens: 600,
    timeout: 30_000,
    onError: ({ error }) => logAiFailure(userId, error),
    onEnd: ({ usage }) => recordTokens(userId, usage.totalTokens ?? 0),
  })

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream, onError: () => 'The assistant is unavailable. Try again later.' }),
  })
})

event.context.user is whatever your auth middleware sets. orderTools and logAiFailure come in steps 4 and 6; Nuxt auto-imports them from server/utils/, like checkBudget. From the top:

  • No session, no model call, and no call for a user over budget.
  • Before anything is sent: Zod allows 1 to 20 messages, safeValidateUIMessages checks that each has the shape the chat UI sends, and the conversation may be at most 20,000 characters of JSON. convertToModelMessages then turns it into the format the model takes.
  • maxOutputTokens caps the answer, isStepCount(3) caps the tool loop, and timeout (in milliseconds) ends a call that hangs.
  • onEnd adds the real token count, summed over all steps, to the user's budget.

Step 4: give tools the least privilege

Prompt injection may never be fully preventable: the LLM01 entry says that, given how models work, it is unclear whether fool-proof prevention exists. So design for the day it succeeds, and make sure a fooled model can do very little. The tool is where that happens.

server/utils/order-tools.tsBreaks
export const orderTools = {
  getOrder: tool({
    description: 'Get an order by id',
    inputSchema: z.object({ orderId: z.string() }),
    execute: async ({ orderId }) => useStorage('orders').getItem(orderId),
  }),
}

Anyone who can make the model ask for another customer's order id gets that order, address included. The model decides whose data to read, and the model can be persuaded.

server/utils/order-tools.tsWorks
import { tool } from 'ai'
import { z } from 'zod'

type Order = { customerId: string, status: string, updatedAt: string, address: string }

export function orderTools(userId: string) {
  return {
    getOrderStatus: tool({
      description: 'Get the delivery status of one of the signed-in customer\'s own orders',
      inputSchema: z.object({ orderId: z.string().regex(/^ord_[a-z0-9]{8}$/) }),
      execute: async ({ orderId }) => {
        const order = await useStorage('orders').getItem<Order>(orderId)
        if (!order || order.customerId !== userId) return { found: false }
        return { found: true, status: order.status, updatedAt: order.updatedAt }
      },
    }),
  }
}

The user id comes from the session through the closure, never from the model. The check runs in code, which OWASP's LLM06 entry calls complete mediation. The tool returns the two fields the answer needs and leaves the address out (LLM02). It reads and cannot write. If you later add a tool that changes something, such as cancelling an order, set toolApproval: { cancelOrder: 'user-approval' } so a person confirms it first.

Step 5: treat the answer as untrusted

Whatever the model writes may contain text an attacker planted, for example in a document it was asked to summarise. OWASP's LLM01 scenarios include hidden instructions that make the model insert an image whose URL carries the private conversation off to another server. So never render model output as HTML:

app/components/AssistantReply.vueBreaks
<script setup lang="ts">
defineProps<{ text: string }>()
</script>

<template>
  <div v-html="text" />
</template>
app/components/AssistantReply.vueWorks
<script setup lang="ts">
defineProps<{ text: string }>()
</script>

<template>
  <p>{{ text }}</p>
</template>

Interpolation escapes everything. If you must render Markdown, sanitise the HTML it produces and do not load remote images. The Content Security Policy that nuxt-security sends is a second line, and LLM05 recommends a strict one. The same rule applies on the server: tool arguments go through the Zod schema, and nothing the model writes is ever passed to eval, a shell or a SQL string.

Step 6: log failures, not prompts, and answer politely

When the provider fails, you need to know, but the obvious console.error(error) is a leak. An APICallError from the SDK carries requestBodyValues, the full request, including every message the user typed. Log the facts you need and nothing else:

server/utils/ai-log.ts
import { APICallError } from 'ai'

export function logAiFailure(userId: string, error: unknown) {
  console.error(JSON.stringify({
    event: 'ai_failure',
    user: userId,
    name: error instanceof Error ? error.name : 'UnknownError',
    status: APICallError.isInstance(error) ? error.statusCode : undefined,
    retryable: APICallError.isInstance(error) ? error.isRetryable : undefined,
    at: new Date().toISOString(),
  }))
}

The user sees only the sentence passed to toUIMessageStream's onError. The SDK's own default is An error occurred., which also hides provider details, but a sentence that says what to do is kinder. Every createError above uses a fixed message, never the text of the error that caused it.

Test each defence before you ship

Start the dev server and fire fifteen requests from the terminal:

for i in $(seq 1 15); do
  curl -s -o /dev/null -w '%{http_code} ' -X POST http://localhost:3000/api/assistant \
    -H 'Content-Type: application/json' -d '{"messages":[]}'
done

You should see eleven 401 responses, because there is no session, then four 429. It is eleven, not ten, because the module's code lets the first request through without spending a token. The IP limit runs before your handler, so it protects the route even from requests that would fail anyway.

Then sign in and try the attacks yourself:

  1. Ask for an order id that belongs to another test account. The tool answers found: false.
  2. Paste a long document into the chat. The route refuses it with a 400 before any tokens are spent.
  3. Ask the model, in plain words, to reply with a <script> tag. The page shows the tag as text. Type the tag yourself and nuxt-security's XSS validator, on by default, refuses the message with a 400, as it does a lone < or >.
  4. If you mounted Redis or another store in step 2, stop it. The request fails with a 500 and the model is never called.
  5. Point the provider key at nonsense. The user sees one calm sentence, and your log shows a status code and no message text.
Check it stuck3 questions from the API security, errors and logging quiz.
Practice now
Was this helpful?

Keep reading