---
title: "Broken access control: why hiding a button is not security · vuejs.school"
description: "A v-if hides a button, not the endpoint behind it. Put role, ownership and URL checks on the server in Nuxt, and log every request you refuse."
canonical_url: "https://vuejs.school/community/blog/broken-access-control-why-hiding-a-button-is-not-security"
meta:
  "og:description": "A v-if hides a button, not the endpoint behind it. Put role, ownership and URL checks on the server in Nuxt, and log every request you refuse."
  "og:image": "https://vuejs.school/_og/d/a_~QnJva2VuIGFjY2VzcyBjb250cm9sOiB3aHkgaGlkaW5nIGEgYnV0dG9uIGlzIG5vdCBzZWN1cml0eSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QnJva2VuIGFjY2VzcyBjb250cm9sOiB3aHkgaGlkaW5nIGEgYnV0dG9uIGlzIG5vdCBzZWN1cml0eQ%2Cdescription_~QSB2LWlmIGhpZGVzIGEgYnV0dG9uLCBub3QgdGhlIGVuZHBvaW50IGJlaGluZCBpdC4gUHV0IHJvbGUsIG93bmVyc2hpcCBhbmQgVVJMIGNoZWNrcyBvbiB0aGUgc2VydmVyIGluIE51eHQsIGFuZCBsb2cgZXZlcnkgcmVxdWVzdCB5b3UgcmVmdXNlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9icm9rZW4tYWNjZXNzLWNvbnRyb2wtd2h5LWhpZGluZy1hLWJ1dHRvbi1pcy1ub3Qtc2VjdXJpdHki%2Cs_TLyO51RKhRBaFOdJ.png"
  "og:image:alt": "Broken access control: why hiding a button is not security · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Broken access control: why hiding a button is not security · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~QnJva2VuIGFjY2VzcyBjb250cm9sOiB3aHkgaGlkaW5nIGEgYnV0dG9uIGlzIG5vdCBzZWN1cml0eSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QnJva2VuIGFjY2VzcyBjb250cm9sOiB3aHkgaGlkaW5nIGEgYnV0dG9uIGlzIG5vdCBzZWN1cml0eQ%2Cdescription_~QSB2LWlmIGhpZGVzIGEgYnV0dG9uLCBub3QgdGhlIGVuZHBvaW50IGJlaGluZCBpdC4gUHV0IHJvbGUsIG93bmVyc2hpcCBhbmQgVVJMIGNoZWNrcyBvbiB0aGUgc2VydmVyIGluIE51eHQsIGFuZCBsb2cgZXZlcnkgcmVxdWVzdCB5b3UgcmVmdXNlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9icm9rZW4tYWNjZXNzLWNvbnRyb2wtd2h5LWhpZGluZy1hLWJ1dHRvbi1pcy1ub3Qtc2VjdXJpdHki%2Cs_TLyO51RKhRBaFOdJ.png"
  "twitter:image:alt": "Broken access control: why hiding a button is not security · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.ts**Breaks***

```
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.ts**Works***

```
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.ts**Breaks***

```
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.ts**Works***

```
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.ts**Breaks***

```
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.ts**Works***

```
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 stuck**3 questions from the Broken access control and SSRF quiz.

[**Practice now**](https://vuejs.school/learn/security/broken-access-control?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 7 min read**API security for AI features: rate limits, prompt injection and error handling**](https://vuejs.school/community/blog/api-security-for-ai-features) [Tutorial · 8 min read**Supply chain security: lockfiles, provenance and OWASP A03**](https://vuejs.school/community/blog/supply-chain-security-lockfiles-provenance-and-owasp-a03)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/broken-access-control-why-hiding-a-button-is-not-security#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-05-19","description":"A v-if hides a button, not the endpoint behind it. Put role, ownership and URL checks on the server in Nuxt, and log every request you refuse.","name":"Broken access control: why hiding a button is not security","url":"https://vuejs.school/community/blog/broken-access-control-why-hiding-a-button-is-not-security","breadcrumb":{"@id":"https://vuejs.school/community/blog/broken-access-control-why-hiding-a-button-is-not-security#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/broken-access-control-why-hiding-a-button-is-not-security"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/broken-access-control-why-hiding-a-button-is-not-security#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-05-19","description":"A v-if hides a button, not the endpoint behind it. Put role, ownership and URL checks on the server in Nuxt, and log every request you refuse.","headline":"Broken access control: why hiding a button is not security","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~QnJva2VuIGFjY2VzcyBjb250cm9sOiB3aHkgaGlkaW5nIGEgYnV0dG9uIGlzIG5vdCBzZWN1cml0eSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QnJva2VuIGFjY2VzcyBjb250cm9sOiB3aHkgaGlkaW5nIGEgYnV0dG9uIGlzIG5vdCBzZWN1cml0eQ%2Cdescription_~QSB2LWlmIGhpZGVzIGEgYnV0dG9uLCBub3QgdGhlIGVuZHBvaW50IGJlaGluZCBpdC4gUHV0IHJvbGUsIG93bmVyc2hpcCBhbmQgVVJMIGNoZWNrcyBvbiB0aGUgc2VydmVyIGluIE51eHQsIGFuZCBsb2cgZXZlcnkgcmVxdWVzdCB5b3UgcmVmdXNlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9icm9rZW4tYWNjZXNzLWNvbnRyb2wtd2h5LWhpZGluZy1hLWJ1dHRvbi1pcy1ub3Qtc2VjdXJpdHki%2Cs_TLyO51RKhRBaFOdJ.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/broken-access-control-why-hiding-a-button-is-not-security#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/broken-access-control-why-hiding-a-button-is-not-security#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/broken-access-control-why-hiding-a-button-is-not-security#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Broken access control: why hiding a button is not security","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~QnJva2VuIGFjY2VzcyBjb250cm9sOiB3aHkgaGlkaW5nIGEgYnV0dG9uIGlzIG5vdCBzZWN1cml0eSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QnJva2VuIGFjY2VzcyBjb250cm9sOiB3aHkgaGlkaW5nIGEgYnV0dG9uIGlzIG5vdCBzZWN1cml0eQ%2Cdescription_~QSB2LWlmIGhpZGVzIGEgYnV0dG9uLCBub3QgdGhlIGVuZHBvaW50IGJlaGluZCBpdC4gUHV0IHJvbGUsIG93bmVyc2hpcCBhbmQgVVJMIGNoZWNrcyBvbiB0aGUgc2VydmVyIGluIE51eHQsIGFuZCBsb2cgZXZlcnkgcmVxdWVzdCB5b3UgcmVmdXNlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9icm9rZW4tYWNjZXNzLWNvbnRyb2wtd2h5LWhpZGluZy1hLWJ1dHRvbi1pcy1ub3Qtc2VjdXJpdHki%2Cs_TLyO51RKhRBaFOdJ.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~QnJva2VuIGFjY2VzcyBjb250cm9sOiB3aHkgaGlkaW5nIGEgYnV0dG9uIGlzIG5vdCBzZWN1cml0eSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QnJva2VuIGFjY2VzcyBjb250cm9sOiB3aHkgaGlkaW5nIGEgYnV0dG9uIGlzIG5vdCBzZWN1cml0eQ%2Cdescription_~QSB2LWlmIGhpZGVzIGEgYnV0dG9uLCBub3QgdGhlIGVuZHBvaW50IGJlaGluZCBpdC4gUHV0IHJvbGUsIG93bmVyc2hpcCBhbmQgVVJMIGNoZWNrcyBvbiB0aGUgc2VydmVyIGluIE51eHQsIGFuZCBsb2cgZXZlcnkgcmVxdWVzdCB5b3UgcmVmdXNlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9icm9rZW4tYWNjZXNzLWNvbnRyb2wtd2h5LWhpZGluZy1hLWJ1dHRvbi1pcy1ub3Qtc2VjdXJpdHki%2Cs_TLyO51RKhRBaFOdJ.png"}]}
```