API security, errors and logging
Map the OWASP API Security Top 10 onto Nuxt server routes, accept only the fields a person may change, fail closed when a check breaks, answer without internal details, and log security events without secrets.
What you'll learn
When you finish, you can do these.- 1Check your server routes against the API Security Top 10
- 2Accept only listed fields and return what the page shows
- 3Deny when a check fails, without leaking error details
- 4Log security events without secrets and alert on them
The Corner Shop, the small online shop this track keeps safe, is a Nuxt app: Vue with its own server. Its profile form sends the server two fields, a display name and a bio. Mallory, a customer, opens DevTools, the browser's tools for developers, and sends the same request with one more field: "role": "admin". Whether that makes her an admin depends on one line in the server route that receives it.
The Code tab holds the shop's corrected server code: a profile route that saves only the fields it lists, a cancel route that says no when its check fails, and a plugin that logs failures without secrets. You need one idea from Authentication, sessions and CSRF: requireUser() returns the signed-in customer from the session cookie, with their id and role.
The API Top 10 in a Nuxt server folder
An API is the set of addresses a server answers with data, so every Nuxt app with a server/api folder has one. OWASP, the group behind the best-known Top 10 of web risks, keeps a second list for APIs: the API Security Top 10 (2023). This track covers most of it:
- API1 Broken Object Level Authorization and API5 Broken Function Level Authorization: the owner and admin checks from Broken access control and SSRF.
- API2 Broken Authentication: the login, sessions and rate limits from Authentication, sessions and CSRF.
- API3 Broken Object Property Level Authorization: which fields of a record a person may change or see. This lesson.
- API4 Unrestricted Resource Consumption: limits on request rates, body sizes and page sizes.
- API6 Unrestricted Access to Sensitive Business Flows: a flow such as checkout abused by a script, with no bug in the code.
- API7 Server Side Request Forgery and API8 Security Misconfiguration: covered in the track's lessons on SSRF and on headers.
- API9 Improper Inventory Management: an old
/api/v1/route still answering after everyone moved to v2. - API10 Unsafe Consumption of APIs: data from a payment provider or partner API, trusted without the checks you give user input.
API3 has two halves. Mass assignment is saving a field the visitor was never meant to set, such as Mallory's role. Excessive data exposure is sending back a field the page never needed, such as passwordHash, the stored form of her password.
The profile form's update, a PATCH request, goes to server/api/profile.patch.ts in the Code tab. There the request's body, the data it carries, meets the first of four guards: a schema, written with Zod, a library that checks the shape of data.
- Bodystrict schema
- Handlerfail closed
- Error hooklog, no secrets
- Alerta person looks
The schema names the two fields a customer may change, and .strict() refuses any other:
const ProfileUpdate = z.object({
displayName: z.string().trim().min(1).max(60),
bio: z.string().max(500).optional(),
}).strict()
Mallory's request now gets a 400, the status code for a refused request, and your logs record the attempt. Without .strict(), Zod silently drops unknown keys and the request succeeds, so you never learn someone tried.
The route takes the account's id from requireUser(), never from the body, and returns only the display name and bio.
Fail closed
Failing closed means that when the handler, the function that answers a request, cannot tell whether something is allowed, the answer is no. A payment that fails halfway must undo every step, not leave the order marked paid. On OWASP's main list, the Top 10:2025, these error paths, the ones nobody tests, are A10 Mishandling of Exceptional Conditions.
A customer may cancel an order until it ships, and only the shop's warehouse system knows if it has. The cancel route, server/api/orders/[id]/cancel.post.ts, asks it through canCancel(), a call that can time out. So the route starts from allowed = false. If the check throws an error, the route's catch block logs the details and answers 503 Try again later, the code for a service that is down.
What the visitor reads matters too. A production build, the version you deploy, answers Server Error for any error you did not throw yourself. Only development shows its message and a stack of file paths.
A production build still passes on what you give createError(), Nuxt's function for throwing an error with a status. If a catch passes on a database error with createError({ status: 500, message: error.message }), the browser receives connect ECONNREFUSED db.internal:5432 (password=hunter2): the database's address and password. Give expected errors a short statusText and keep the rest in the logs.
Log security events, alert on patterns
Every failed request should leave one line that can be searched and counted, so an alert can fire. Nitro, Nuxt's server, runs the files in server/plugins when it starts. The shop's plugin, server/plugins/security-log.ts, hooks into every error response and writes one JSON line: the time, a kind such as auth.failed or access.denied, the status, the method, the path without its query string, and the visitor's IP address.
Never log request headers, which carry sign-in tokens (Authorization: Bearer ...) and the session cookie, or bodies, which carry passwords.
Logs nobody reads protect nobody. Send them to a log service and alert on patterns: dozens of auth.failed lines from one IP address in a minute, a burst of access.denied for one account trying order ids one after another, rate.limited on the login route, or server errors (500s) right after a deploy.
What goes wrong
The first mistake is a profile route that saves whatever arrives.
const user = await requireUser(event)
const changes = await readBody(event)
return db.accounts.update(user.id, changes)
Mallory's PATCH with {"displayName":"Mallory","role":"admin"} answers 200, the code for success, and the body that comes back is her whole account: "role": "admin", plus her passwordHash. List the fields, refuse the rest, and return only what the page shows:
const changes = await readValidatedBody(event, ProfileUpdate)
const account = await db.accounts.update(user.id, changes)
return { displayName: account.displayName, bio: account.bio }
The same request now gets a 400 whose data.issues says Unrecognized key: "role".
The second is a catch that logs and carries on.
let allowed = true
try {
allowed = await canCancel(id, user.id)
}
catch (error) {
console.error('cancel check failed', error)
}
While the warehouse is up, canCancel() says no to a shipped order, and the route answers 403, the code for forbidden. The day it times out, every cancel answers 200 and orders that already left the building are cancelled and refunded. Start from no, and stop when the check fails:
let allowed = false
try {
allowed = await canCancel(id, user.id)
}
catch (error) {
console.error('cancel check failed', { orderId: id, userId: user.id, error })
throw createError({ status: 503, statusText: 'Try again later' })
}
A failed check now answers 503 Try again later, and the order stays active.
What to remember
- Every Nuxt server route is part of an API. Check each one against the API Security Top 10.
- Accept a fixed list of fields with a
.strict()schema, take ids fromrequireUser(), never the body, and return only what the page needs. - When a check cannot decide, deny: start from
false, answer 503 when a service you call fails, and roll back half-done work. - Production hides unexpected errors, but not the message you pass to
createError(). Keep internal text in the logs. - Log security events as structured lines without headers, bodies or tokens, and alert on the patterns.
In your project
Do these in a real project. Each one says how to confirm it worked.- Signed in as a customer with
curl -c cookies.txt, sendcurl -i -X PATCH -b cookies.txt -H 'content-type: application/json' -d '{"displayName":"Mallory","role":"admin"}' http://localhost:3000/api/profile. The answer is 400, and its body namesUnrecognized key: "role". Remove.strict()and repeat: 200, androleis still unchanged. - Point the warehouse URL in
server/utils/warehouse.tsat a port where nothing listens, such ashttp://127.0.0.1:59999, and cancel an order: 503 Try again later, and the order is still active. Swap in the brokencatchand the same request answers 200. - Trigger a 401 and a 429 at login, a 403 on an admin route, then search your server output with
grep -i -E 'authorization|bearer|password|nuxt-session'. It finds nothing, whilegrep '"kind"'shows one line per failure.