Skip to content
TutorialSecurity & OWASP

Broken access control: why hiding a button is not security

Broken access control is first in the OWASP Top 10:2025. Here are three Nuxt routes that leak, the fix for each, a middleware that denies by default, and a log line for every refusal.

vuejs.school team19 May 20266 min readNuxt 4.4 · nuxt-auth-utils 0.5

Access control decides who may do what: who can read an order, who can delete a user, which URLs your server will fetch. When it fails, people read or change data that is not theirs. OWASP's Top 10:2025 keeps Broken Access Control in first place, and its A01 page says every application in its test data had some form of it. The same page now includes Server-Side Request Forgery, which used to have its own entry.

You will fix three leaking routes in a Nuxt 4.4 app that handles sessions with nuxt-auth-utils 0.5: a delete endpoint protected only by a hidden button, an order route that returns anyone's order, and a link preview that fetches any URL. Pages live in app/, server routes in server/ at the project root. The routes keep users and orders in Nitro's built-in key-value storage, useStorage('data:…'), so they run without a database.

Check the role in the server route, not the template

Here is an admin screen. The session carries the user's role so the page knows what to show:

app/pages/admin/users.vue
<script setup lang="ts">
const { user } = useUserSession()
const { data: users, refresh } = await useFetch('/api/users')

async function remove(id: string) {
  await $fetch(`/api/users/${id}`, { method: 'DELETE' })
  await refresh()
}
</script>

<template>
  <ul>
    <li v-for="person in users" :key="person.id">
      {{ person.name }}
      <button v-if="user?.role === 'admin'" type="button" @click="remove(person.id)">Delete</button>
    </li>
  </ul>
</template>

The role field sits on the User interface you declare for #auth-utils in shared/types/auth.d.ts, next to id and name.

Nothing is wrong with this page: hiding a button a user cannot use is good design. The problem is the route behind it:

server/api/users/[id].delete.tsBreaks
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')!
  await useStorage('data:users').removeItem(id)
  return { deleted: id }
})

The v-if runs in the browser, and the browser belongs to the user. Anyone can open the network tab, copy the request and send curl -X DELETE https://example.com/api/users/42. OWASP gives this exact case as an example attack: an application that puts all its access control in the front end. A route middleware in app/middleware/ that redirects non-admins away from the page does not help either. It guards the page, never the API route, so it is a convenience, not a lock.

The check belongs in the handler that does the work (getRole is a small helper, shown below):

server/api/users/[id].delete.tsWorks
export default defineEventHandler(async (event) => {
  const { user } = await requireUserSession(event)
  if (await getRole(user.id) !== 'admin') {
    throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
  }
  const id = getRouterParam(event, 'id')!
  await useStorage('data:users').removeItem(id)
  return { deleted: id }
})

Each refusal has its own status code. requireUserSession answers 401, which means "log in first", when nobody is logged in. The 403 means "you are logged in, and the answer is no". A third, 404, comes up below, for when the server should not even admit that something exists.

getRole lives in server/utils/, so Nitro imports it into every server route and middleware for you. It reads the role from the stored user, not from the session:

server/utils/roles.ts
type StoredUser = { id: string, name: string, role: 'admin' | 'member' }

export async function getRole(userId: string) {
  const stored = await useStorage<StoredUser>('data:users').getItem(userId)
  return stored?.role ?? null
}

The session cookie is sealed (encrypted and signed by the server), so a user cannot forge a role in it. But a role copied into it stays there until the session ends or the next login replaces it, even after you demote someone. For a decision that deletes data, read the current role.

Deny by default with server middleware

Checking in every handler works until someone adds a new admin route and forgets. OWASP's first prevention rule is to deny by default except for public resources. In Nuxt, put every admin endpoint under server/api/admin/ and let a server middleware guard the whole folder:

server/middleware/admin.ts
export default defineEventHandler(async (event) => {
  if (!event.path.startsWith('/api/admin/')) return

  const { user } = await requireUserSession(event)
  if (await getRole(user.id) !== 'admin') {
    throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
  }
})

Files in server/middleware/ run on every request before any server route, so the first line lets everything outside /api/admin/ through. Returning nothing lets the request continue; throwing stops it. A new file dropped into server/api/admin/ is protected the moment it exists, with no code of its own.

Keep the check inside sensitive handlers too. The middleware is a safety net, and two checks cost almost nothing.

Check ownership on every route that takes an id

The second leak is quieter, because the route does check the session:

server/api/orders/[id].get.tsBreaks
type Order = { id: string, userId: string, total: number }

export default defineEventHandler(async (event) => {
  await requireUserSession(event)
  const order = await useStorage<Order>('data:orders').getItem(getRouterParam(event, 'id')!)
  if (!order) throw createError({ statusCode: 404, statusMessage: 'Not Found' })
  return order
})

Any logged-in user can load /api/orders/1041, then 1042, then 1043. That is an insecure direct object reference, IDOR for short: the server trusts an id from the URL without asking whether this user may see that object.

Random ids such as UUIDs make guessing harder, but ids leak through shared links, logs and screenshots, so they are not a control. The fix is an ownership check on the object itself:

server/api/orders/[id].get.tsWorks
type Order = { id: string, userId: string, total: number }

export default defineEventHandler(async (event) => {
  const { user } = await requireUserSession(event)
  const order = await useStorage<Order>('data:orders').getItem(getRouterParam(event, 'id')!)
  if (!order || order.userId !== user.id) {
    throw createError({ statusCode: 404, statusMessage: 'Not Found' })
  }
  return order
})

Answer 404 for someone else's order, the same as for a missing one, so the response does not confirm that order 1042 exists.

Apply the same rule to every PUT, PATCH and DELETE route with an id in its path; OWASP names missing checks on POST, PUT and DELETE as a common failure. With a real database, put the owner in the query itself (where id = ? and user_id = ?), so a forgotten if cannot leak a row.

Allowlist the URLs your server fetches

Server-Side Request Forgery, SSRF, happens when your server fetches a URL that the user chose. Your server sits inside your network, so it can reach things the user cannot: the cloud provider's metadata address 169.254.169.254, a database admin panel on localhost, or your own admin routes. A link preview is the classic way in:

server/api/preview.get.tsBreaks
export default defineEventHandler(async (event) => {
  const { url } = getQuery(event)
  const html = await $fetch<string>(String(url), { responseType: 'text' })
  return { title: html.match(/<title>(.*?)<\/title>/i)?.[1] ?? '' }
})

A request to /api/preview?url=http://169.254.169.254/latest/meta-data/ asks the server to read its own cloud credentials. Blocking "localhost" does not help, because 127.0.0.1, [::1] and any domain name that resolves to an internal address reach the same place. Allow known hosts instead of blocking bad ones:

server/api/preview.get.tsWorks
const ALLOWED_HOSTS = new Set(['vuejs.org', 'nuxt.com', 'github.com'])

export default defineEventHandler(async (event) => {
  await requireUserSession(event)

  let target: URL
  try {
    target = new URL(String(getQuery(event).url))
  }
  catch {
    throw createError({ statusCode: 400, statusMessage: 'Invalid URL' })
  }

  if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.hostname)) {
    throw createError({ statusCode: 400, statusMessage: 'This site is not allowed' })
  }

  const html = await $fetch<string>(target.href, { responseType: 'text', redirect: 'error', timeout: 5000 })
  return { title: html.match(/<title>(.*?)<\/title>/i)?.[1] ?? target.hostname }
})

new URL parses the address so you compare the real host name, not a string that merely contains "nuxt.com". The protocol check rules out http:, file: and friends. redirect: 'error' matters as much as the allowlist: without it, an allowed site that redirects to an internal address would carry your server along.

If your product must fetch arbitrary sites, do it from a separate service with no access to your internal network.

Log every refusal

A refused request is information. One 403 is a mistake; fifty in a minute from the same address is someone walking through your ids. OWASP's prevention list includes logging access control failures and alerting on repeated ones. A small helper keeps the log lines consistent:

server/utils/deny.ts
import type { H3Event } from 'h3'

export function deny(event: H3Event, reason: string, statusCode = 403): never {
  console.warn(JSON.stringify({
    type: 'access_denied',
    reason,
    method: event.method,
    path: event.path,
    ip: getRequestIP(event, { xForwardedFor: true }),
    at: new Date().toISOString(),
  }))
  throw createError({ statusCode, statusMessage: statusCode === 404 ? 'Not Found' : 'Forbidden' })
}

Replace the 403 and 404 throws above with calls such as deny(event, 'not order owner', 404). The user sees the same answer as before, and your logs get one JSON line per refusal, ready for whatever alerting your host provides. Log the reason, the route and the address, and never the session cookie or a token.

Take care with xForwardedFor: true: h3 then reads the first address in the X-Forwarded-For header, which any client can fake. Turn it on only behind a proxy that replaces that header with the real address; otherwise leave it off, and h3 uses the address of the connection.

Test the routes from outside the browser

Test access control the way an attacker would: without your interface.

  1. Start the dev server and log in as an ordinary member in the browser.
  2. Copy the session cookie's value from the developer tools. It is called nuxt-session unless you renamed it.
  3. Set MEMBER_COOKIE in your shell to that value, then send the requests yourself:
# No session: expect 401
curl -i -X DELETE http://localhost:3000/api/users/42

# A member deleting a user: expect 403
curl -i -X DELETE http://localhost:3000/api/users/42 -H "Cookie: nuxt-session=$MEMBER_COOKIE"

# A member reading someone else's order: expect 404
curl -i http://localhost:3000/api/orders/1042 -H "Cookie: nuxt-session=$MEMBER_COOKIE"

# An internal address in the preview: expect 400
curl -i "http://localhost:3000/api/preview?url=http://169.254.169.254/latest/meta-data/" -H "Cookie: nuxt-session=$MEMBER_COOKIE"

Every line should fail with the code in its comment, and any 200 is a hole. OWASP also asks developers to put access control in their unit and integration tests, so turn these four requests into tests once they pass. A refactor that drops a guard then fails the build instead of reaching production.

Check it stuck3 questions from the Broken access control and SSRF quiz.
Practice now
Was this helpful?

Keep reading