Broken access control and SSRF
Check in every handler that the record or action belongs to the person asking, keep admin checks on the server, allow-list CORS origins, and never let a visitor choose where your server sends requests.
What you'll learn
When you finish, you can do these.- 1Check and test that a record belongs to the person asking
- 2Refuse admin actions to customers in the server route
- 3Let only your own apps read your API with cookies
- 4Make a link preview route safe from SSRF
The Corner Shop, the small online shop this track keeps safe, is a Nuxt app (Vue with its own server). Its order page loads each order by number from an API route, a server address that answers with data. Ana, a customer, reads hers from /api/orders/1042; when she types 1043 in the address bar instead, she gets the order of Ben, another customer, with his name and delivery address. No error, no warning: the server answers 200, the code for success. This is broken access control, first on the OWASP Top 10:2025, the list of web risks most teams use.
You need one idea from Authentication, sessions and CSRF: requireUser(), which each private route calls first, tells it who is asking and answers 401 when nobody is signed in. Every route must also ask: may this person do this, to this record? The Code tab holds the shop's routes that ask it: an owner check on each order, a permission list for admin actions, a route the shop's second app reads with a cookie, and a link preview route.
Check the owner in the handler
The missing check is authorisation. Authentication proves who someone is; authorisation decides what they may touch. Ana is signed in, so requireUser() lets her through, and nothing compares order 1043 with Ana. That comparison belongs in the handler, the server function that answers the route: it is where the order is loaded, and every request to the route passes through it.
browser requestserver middlewareevery requestroute middlewarepage navigationshandler or pageresponse
In server/api/orders/[id].get.ts, the order route in the Code tab, the handler loads the order named in the URL with db.orders.find(), the shop's database call, then compares its owner with the signed-in user:
const user = await requireUser(event)
const order = await db.orders.find(Number(getRouterParam(event, 'id')))
if (!order || order.ownerId !== user.id) {
throw createError({ status: 404, statusText: 'Order not found' })
}
return order
Ana's request for order 1043 now answers 404 Order not found, exactly like an order that does not exist, so a guesser cannot map which ids are real. A check on one record is called object-level authorisation.
Lists follow the same rule. server/api/orders.get.ts, behind Ana's list of orders, filters by owner in the database query with db.orders.forOwner(user.id). Hiding other people's orders with v-if would still send them to the browser, in the page's payload, the data Nuxt ships with the page.
Test it the way an attacker would, with two accounts. tests/e2e/orders.spec.ts, written with Playwright (a tool that drives browsers and sends requests to your running app), signs in as Ana and asks for her order, then Ben's:
const ana = await playwright.request.newContext({ baseURL })
await signIn(ana, 'ana@example.com', process.env.ANA_PASSWORD!)
expect((await ana.get('/api/orders/1042')).status()).toBe(200) // her own
expect((await ana.get('/api/orders/1043')).status()).toBe(404) // Ben's
Remove the owner check and it fails with Expected: 404 and Received: 200. A later change that drops the check now breaks a test instead of leaking data.
Admin actions need their own check
Some routes guard an action rather than a record. /api/admin/users lists every account, and a customer who finds it must get a 403 (signed in, but not allowed), not the list. This check is function-level authorisation.
Deny by default. server/utils/permissions.ts lists what each role may do, and requirePermission() refuses anything that is not listed. SessionUser, the signed-in user's type, holds the role: customer or admin.
const allowed: Record<SessionUser['role'], string[]> = {
customer: ['orders:read-own', 'profile:write-own'],
admin: ['orders:read-own', 'orders:read-any', 'users:list', 'profile:write-own'],
}
export function requirePermission(user: SessionUser, action: string) {
if (!allowed[user.role]?.includes(action)) {
throw createError({ status: 403, statusText: 'Forbidden' })
}
}
The users route, server/api/admin/users.get.ts, calls requirePermission(user, 'users:list') before it reads anything. The customer row has no 'users:list', so a customer gets 403 Forbidden. A new action or role starts closed, and opening it is a line you can see in a review.
app/middleware/admin.ts, the route middleware that runs before an admin page opens, only spares customers a page they cannot use. It protects nothing: API calls skip it.
CORS and SSRF
Two more holes let others use your server: reading a customer's data from another page, and choosing what it fetches.
A browser lets a page read a response from another origin (a different protocol, host or port) only when the response allows it with CORS headers (cross-origin resource sharing). Access-Control-Allow-Origin: *, meaning any origin, is fine for public data such as a product list, and browsers refuse it for requests that carry cookies.
The shop, on https://cornershop.example, also runs a second app of its own on https://app.cornershop.example. That app reads a customer's orders from the route in server/api/partner/orders.get.ts, so it needs the cookie.
The hole opens when that route answers every origin. handleCors(), the helper that writes CORS headers, does that when called as handleCors(event, { origin: () => true, credentials: true }): it copies the calling page's origin into Access-Control-Allow-Origin and adds Access-Control-Allow-Credentials: true, which lets that page read answers sent with cookies.
Then any page whose requests carry the cookie can read a customer's orders. The shop's SameSite=Lax cookie goes along from any page on the same site, such as another port of cornershop.example or a forum on forum.cornershop.example. A SameSite=None cookie goes along from any site at all. List the origins you own instead, as the route in the Code tab does: origin: ['https://app.cornershop.example'].
The second hole is in link previews. For a link in a product description, /api/preview?url=... fetches the page and returns its title. If it fetches any URL, the visitor picks where your server sends requests, including addresses only your server can reach, such as http://169.254.169.254, where cloud hosts serve their credentials. This is server-side request forgery (SSRF).
The route's file in the Code tab, server/api/preview.get.ts, closes four doors:
- It parses the URL inside
try, becausenew URL('not-a-url')throws an error, and answers 400 instead of a 500 server error. - It allows only
https:and the hosts inALLOWED_HOSTS, a fixed list. - It sets
redirect: 'manual'and refuses redirects, becausefetch, like Nuxt's$fetch, follows them: an allowed host could send your server on to an internal address. - It gives up after five seconds, with
AbortSignal.timeout(5000).
What goes wrong
A route that calls requireUser() and stops there.
const user = await requireUser(event)
return db.orders.find(Number(getRouterParam(event, 'id')))
Signed out, you get a 401, so it looks protected. Signed in as anyone, every order id answers 200 with someone's address. Compare the owner:
const order = await db.orders.find(Number(getRouterParam(event, 'id')))
if (!order || order.ownerId !== user.id) {
throw createError({ status: 404, statusText: 'Order not found' })
}
An admin check that exists only in route middleware.
export default defineEventHandler(async (event) => {
await requireUser(event) // app/middleware/admin.ts keeps customers off /admin
return db.accounts.list()
})
In the browser, a customer who opens /admin is sent home, so the feature looks locked. But curl -b cookies.txt https://cornershop.example/api/admin/users, a request from the terminal with a customer's cookie saved in cookies.txt, returns every account: route middleware never runs for an API call. Check the action in the handler:
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
requirePermission(user, 'users:list')
return db.accounts.list()
})
What to remember
- Every handler that takes an id compares the record's owner with the signed-in user, and answers 404 when they differ. A test with two accounts keeps it that way.
- Filter lists by owner in the query. A
v-ifin the page hides rows that have already been sent. - Admin actions are checked in the server route. Route middleware is for the visitor's experience only.
- A wildcard CORS origin is safe for public data. Copying back any origin while allowing cookies is the leak; list your origins.
- A server that fetches a visitor's URL allow-lists hosts, answers 400 for bad input, refuses redirects and sets a timeout.
In your project
Do these in a real project. Each one says how to confirm it worked.- Add
tests/e2e/orders.spec.tswith two seeded accounts and runnpx playwright test orders: it passes. Delete the owner comparison fromorders/[id].get.tsand run it again: it fails with Expected: 404, Received: 200. Put the check back. - Sign in as a customer with
curl -c cookies.txt, then call every route underserver/api/adminwithcurl -i -b cookies.txt. Each must answer 403 Forbidden. Any 200 is a route that trusts the middleware. - Run
grep -rln getRouterParam server/apiand open each file. Every one should compare an owner or callrequirePermission()before it returns data. Then call/api/preview?url=not-a-urland/api/preview?url=http://169.254.169.254/with the same cookie: both answer 400, not 500.