ASVS Level 1 Authorization in Node.js and Go

Series OWASP ASVS 5.0 Level 1 Part 10/13 All parts

Authentication answers one question: who is this? Authorization answers the next one: what is this person allowed to do? Chapter V8 Authorization of the OWASP Application Security Verification Standard has four Level 1 requirements, and they split cleanly. One says write the rules down. Two say enforce them, once for whole functions and once for individual records. The last says enforce them somewhere the user cannot reach.

This part covers all four with runnable code in Node.js with Fastify and in Go with Fiber. Pick your stack once and the whole article follows it. Every control is a broken version next to a fixed version, one small change apart, so you can run both and see the difference.

Part 9 covered how the server works out who is calling. This part assumes that already works and starts from the answer.

Conceptual Overview

Authorization is not a login check. A logged-in user is still a stranger to most of your data. Once the request has a name attached, every route still has to decide whether that particular name is allowed to run that particular action on that particular record.

There are two separate questions, and most applications only answer the first. Function-level authorization asks whether this user may call this operation at all: may a normal member list every account in the system? Data-level authorization asks whether this user may touch this specific record: may Alice read invoice 1002, which belongs to Bob? A role check answers the first question and says nothing about the second.

Missing data-level checks have two names. In web applications the bug is called insecure direct object reference (IDOR): the identifier of a record appears in the URL, and changing it hands you somebody else’s record. In API security the same bug is called broken object level authorization (BOLA). They are the same mistake, and it is the most common serious flaw in production APIs, because the code looks finished. The record is found, the response is valid JSON, nothing throws.

A check is only worth what the attacker cannot change. If the decision depends on a value the client sent, then the client decides. Hiding a button in the browser, disabling a form field, or trusting a header like X-Role are all the same failure: the rule lives on the wrong side of the network.

Rules you never wrote down cannot be tested. The documentation requirement is not paperwork. It is the list a reviewer reads before looking at the code, and the list your tests are written from. Without it, “is this route protected?” has no correct answer to compare against.

Prerequisites

Step 1: Set Up a Scratch Project

The examples are an invoicing API with three accounts. Alice and Bob are ordinary members, Dana is an admin. Each control is a pair of servers named -bad and -good, both listening on port 3000, run one at a time.

Sessions are Part 9’s job, so these servers skip the login flow and use three fixed tokens: tok_alice, tok_bob, and tok_dana. Real code would look the token up in a session store. What matters here is that the token maps to an email and a role on the server, and that the client never sends either of them.

mkdir -p ~/asvs-v8/node
cd ~/asvs-v8/node
npm init -y
npm pkg set type=module
npm install fastify

Run a server with node function-bad.mjs, and stop it with Ctrl+C before starting the next one.

mkdir -p ~/asvs-v8/go
cd ~/asvs-v8/go
go mod init asvs-v8
go get github.com/gofiber/fiber/[email protected]

Each server is its own cmd/ directory. Build them into named binaries so you always know which one is running:

go build -o bin/function-bad ./cmd/function-bad
./bin/function-bad

Stop it with Ctrl+C before starting the next one. A leftover server still holding port 3000 will answer requests you think are going to the new one.

Step 2: Write the Rules Down First

V8.1.1 Verify that authorization documentation defines rules for restricting function-level and data-specific access based on consumer permissions and resource attributes.

This is one of four Level 1 requirements in the whole standard that produce a document instead of code. Read it as a checklist of four things the document must name: the permissions a user can hold, the functions each permission unlocks, the data each permission reaches, and the resource attributes that decision uses.

Keep it in the repository next to the code it describes, so a change to a route and a change to the rules land in the same pull request. Create docs/authorization.md:

# Authorization rules

Last reviewed: 2026-08-25

## Roles

- `member`: every account created by self-service signup.
- `admin`: billing staff. Granted only by another admin, never at signup.

## Function-level rules

One line per route. A route that is not listed here is denied.

- `GET /invoices` - member, admin
- `GET /invoices/{id}` - member, admin
- `DELETE /invoices/{id}` - admin
- `GET /admin/accounts` - admin

## Data-level rules

- Invoice: a member may read an invoice only when `invoice.owner` equals the
  email on their own session. An admin may read any invoice.
- Account: a member may read only their own account record.

## Resource attributes these rules depend on

- `session.email` - set by the server at login, never read from the request.
- `session.role` - stored on the account row, never read from the request.
- `invoice.owner` - set once when the invoice is created, never updatable.

The last section is the part people leave out, and it is the one that catches bugs. Writing “never read from the request” next to session.role turns Step 5 of this article into an obvious violation instead of a subtle one.

Verify the document the same way you verify code, by checking it against reality:

grep -rEho "app\.(get|post|delete|put)\('[^']+'" *.mjs | sort -u

Every route that command prints must appear in the function-level list. If one does not, either the document is stale or the route was never meant to exist.

Step 3: Restrict the Function

V8.2.1 Verify that the application ensures that function-level access is restricted to consumers with explicit permissions.

The word to notice is explicit. The rule is not “deny the people I remember to deny”. It is “allow only the people who hold the permission”. Those two produce identical behaviour until somebody adds a new role, and then only the second one still works.

The broken server checks that the caller is logged in and stops there. GET /admin/accounts returns every account in the system to anybody with a valid token.

Create function-bad.mjs:

import Fastify from 'fastify'

const sessions = new Map([
  ['tok_alice', { email: '[email protected]', role: 'member' }],
  ['tok_bob', { email: '[email protected]', role: 'member' }],
  ['tok_dana', { email: '[email protected]', role: 'admin' }],
])

const app = Fastify()

app.addHook('preHandler', async (req, reply) => {
  const token = (req.headers.authorization ?? '').replace('Bearer ', '')
  const session = sessions.get(token)
  if (!session) return reply.code(401).send({ error: 'not logged in' })
  req.session = session
})

app.get('/admin/accounts', async (req) => {
  return { accounts: [...sessions.values()], askedBy: req.session.email }
})

await app.listen({ port: 3000 })

The preHandler hook runs before every route. It answers “who is this”, which is authentication. Nothing after it answers “may they”.

Copy the file to function-good.mjs and add the missing question to the route:

app.get('/admin/accounts', async (req, reply) => {
  if (req.session.role !== 'admin') {
    return reply.code(403).send({ error: 'admin only' })
  }
  return { accounts: [...sessions.values()], askedBy: req.session.email }
})

Create cmd/function-bad/main.go:

package main

import (
	"log"
	"strings"

	"github.com/gofiber/fiber/v3"
)

type session struct {
	Email string `json:"email"`
	Role  string `json:"role"`
}

var accounts = []session{
	{Email: "[email protected]", Role: "member"},
	{Email: "[email protected]", Role: "member"},
	{Email: "[email protected]", Role: "admin"},
}

var sessions = map[string]session{
	"tok_alice": accounts[0],
	"tok_bob":   accounts[1],
	"tok_dana":  accounts[2],
}

func main() {
	app := fiber.New()

	app.Use(func(c fiber.Ctx) error {
		token := strings.TrimPrefix(c.Get("Authorization"), "Bearer ")
		s, ok := sessions[token]
		if !ok {
			return c.Status(401).JSON(fiber.Map{"error": "not logged in"})
		}
		c.Locals("session", s)
		return c.Next()
	})

	app.Get("/admin/accounts", func(c fiber.Ctx) error {
		s := c.Locals("session").(session)
		return c.JSON(fiber.Map{"accounts": accounts, "askedBy": s.Email})
	})

	log.Fatal(app.Listen(":3000"))
}

app.Use runs before every route. It answers “who is this”, which is authentication. Nothing after it answers “may they”.

Copy the directory to cmd/function-good and add the missing question to the handler:

	app.Get("/admin/accounts", func(c fiber.Ctx) error {
		s := c.Locals("session").(session)
		if s.Role != "admin" {
			return c.Status(403).JSON(fiber.Map{"error": "admin only"})
		}
		return c.JSON(fiber.Map{"accounts": accounts, "askedBy": s.Email})
	})

Run each server in turn and ask as Alice, who is only a member:

curl -s -w " HTTP %{http_code}\n" -H "Authorization: Bearer tok_alice" \
  localhost:3000/admin/accounts

Against the broken server, Alice gets the staff list:

{"accounts":[{"email":"[email protected]","role":"member"},{"email":"[email protected]","role":"member"},{"email":"[email protected]","role":"admin"}],"askedBy":"[email protected]"} HTTP 200

Against the fixed server she gets nothing, and Dana still gets the list:

{"error":"admin only"} HTTP 403
{"accounts":[{"email":"[email protected]","role":"member"},...],"askedBy":"[email protected]"} HTTP 200

Use 403 Forbidden here, not 401 Unauthorized. 401 means “I do not know who you are, log in”, and Alice is already logged in. Sending 401 makes correct clients throw away a perfectly good session and show a login screen.

Step 4: Restrict the Data

V8.2.2 Verify that the application ensures that data-specific access is restricted to consumers with explicit permissions to specific data items to mitigate insecure direct object reference (IDOR) and broken object level authorization (BOLA).

The broken server here has no role bug at all. GET /invoices/:id is a route both members and admins are supposed to reach, and the document in Step 2 says so. The bug is that the lookup uses only the identifier from the URL.

Create data-bad.mjs. The session table and the hook are the same as before, with two invoices added:

const invoices = [
  { id: 1001, owner: '[email protected]', amount: 120, note: 'Logo redraw' },
  { id: 1002, owner: '[email protected]', amount: 98000, note: 'Rack of servers' },
]

app.get('/invoices/:id', async (req, reply) => {
  const invoice = invoices.find((i) => i.id === Number(req.params.id))
  if (!invoice) return reply.code(404).send({ error: 'no such invoice' })
  return invoice
})

Copy the file to data-good.mjs and put the caller into the lookup itself:

app.get('/invoices/:id', async (req, reply) => {
  const invoice = invoices.find(
    (i) => i.id === Number(req.params.id) && i.owner === req.session.email,
  )
  if (!invoice) return reply.code(404).send({ error: 'no such invoice' })
  return invoice
})

Create cmd/data-bad/main.go. The session table and the middleware are the same as before, with two invoices added:

type invoice struct {
	ID     int    `json:"id"`
	Owner  string `json:"owner"`
	Amount int    `json:"amount"`
	Note   string `json:"note"`
}

var invoices = []invoice{
	{ID: 1001, Owner: "[email protected]", Amount: 120, Note: "Logo redraw"},
	{ID: 1002, Owner: "[email protected]", Amount: 98000, Note: "Rack of servers"},
}

	app.Get("/invoices/:id", func(c fiber.Ctx) error {
		id, _ := strconv.Atoi(c.Params("id"))
		for _, inv := range invoices {
			if inv.ID == id {
				return c.JSON(inv)
			}
		}
		return c.Status(404).JSON(fiber.Map{"error": "no such invoice"})
	})

Copy the directory to cmd/data-good and put the caller into the lookup itself:

	app.Get("/invoices/:id", func(c fiber.Ctx) error {
		s := c.Locals("session").(session)
		id, _ := strconv.Atoi(c.Params("id"))
		for _, inv := range invoices {
			if inv.ID == id && inv.Owner == s.Email {
				return c.JSON(inv)
			}
		}
		return c.Status(404).JSON(fiber.Map{"error": "no such invoice"})
	})

Ask for Bob’s invoice while logged in as Alice:

curl -s -w " HTTP %{http_code}\n" -H "Authorization: Bearer tok_alice" \
  localhost:3000/invoices/1002

The broken server hands it over, including how much Bob spends:

{"id":1002,"owner":"[email protected]","amount":98000,"note":"Rack of servers"} HTTP 200

The fixed server behaves as if the invoice does not exist, while Alice’s own invoice still works:

{"error":"no such invoice"} HTTP 404
{"id":1001,"owner":"[email protected]","amount":120,"note":"Logo redraw"} HTTP 200

Two details in that fix are worth copying. First, the ownership test is part of the lookup, not an if statement after it. A separate check is easy to forget on the next route, and easy to skip accidentally on an early return. In a real application this is a WHERE id = $1 AND owner = $2 in the query. Second, the answer is 404, not 403. A 403 confirms that invoice 1002 exists and belongs to someone else, which lets an attacker map your database by counting status codes. Returning 404 for “not yours” and for “not there” tells them nothing.

Do not try to solve this by making identifiers unguessable. Replacing 1002 with a random identifier is worth doing, because it keeps record numbers out of screenshots and logs, but it is not an access control. The identifier still travels through URLs, emails, and browser history, and anyone who has seen it once can replay it forever.

Step 5: Keep the Check on the Server

V8.3.1 Verify that the application enforces authorization rules at a trusted service layer and doesn’t rely on controls that an untrusted consumer could manipulate, such as client-side JavaScript.

This one is about where the check runs. The broken server does check the role. It just asks the browser for the answer. It sends a page that hides the delete button from members, and then trusts the X-Role header that the page’s own JavaScript attaches to the request.

Create layer-bad.mjs:

app.get('/page', async (req, reply) => {
  const role = req.session.role
  reply.type('text/html')
  return `<!doctype html>
<button id="del" hidden>Delete invoice 1002</button>
<script>
  const role = ${JSON.stringify(role)}
  if (role === 'admin') document.getElementById('del').hidden = false
  document.getElementById('del').onclick = () =>
    fetch('/invoices/1002', { method: 'DELETE', headers: { 'X-Role': role } })
</script>`
})

app.delete('/invoices/:id', async (req, reply) => {
  if (req.headers['x-role'] !== 'admin') {
    return reply.code(403).send({ error: 'admin only' })
  }
  invoices.delete(Number(req.params.id))
  return { deleted: Number(req.params.id), left: invoices.size }
})

Copy the file to layer-good.mjs and read the role from the session instead of from the request:

app.delete('/invoices/:id', async (req, reply) => {
  if (req.session.role !== 'admin') {
    return reply.code(403).send({ error: 'admin only' })
  }
  invoices.delete(Number(req.params.id))
  return { deleted: Number(req.params.id), left: invoices.size }
})

Create cmd/layer-bad/main.go:

	app.Get("/page", func(c fiber.Ctx) error {
		s := c.Locals("session").(session)
		c.Set("Content-Type", "text/html")
		return c.SendString(fmt.Sprintf(`<!doctype html>
<button id="del" hidden>Delete invoice 1002</button>
<script>
  const role = %q
  if (role === 'admin') document.getElementById('del').hidden = false
  document.getElementById('del').onclick = () =>
    fetch('/invoices/1002', { method: 'DELETE', headers: { 'X-Role': role } })
</script>`, s.Role))
	})

	app.Delete("/invoices/:id", func(c fiber.Ctx) error {
		if c.Get("X-Role") != "admin" {
			return c.Status(403).JSON(fiber.Map{"error": "admin only"})
		}
		id, _ := strconv.Atoi(c.Params("id"))
		delete(invoices, id)
		return c.JSON(fiber.Map{"deleted": id, "left": len(invoices)})
	})

Copy the directory to cmd/layer-good and read the role from the session instead of from the request:

	app.Delete("/invoices/:id", func(c fiber.Ctx) error {
		if c.Locals("session").(session).Role != "admin" {
			return c.Status(403).JSON(fiber.Map{"error": "admin only"})
		}
		id, _ := strconv.Atoi(c.Params("id"))
		delete(invoices, id)
		return c.JSON(fiber.Map{"deleted": id, "left": len(invoices)})
	})

Load the page as Alice and the button really is hidden:

curl -s -H "Authorization: Bearer tok_alice" localhost:3000/page
<!doctype html>
<button id="del" hidden>Delete invoice 1002</button>
<script>
  const role = "member"
  ...
</script>

That hidden attribute is a user interface decision, and a good one: do not show people buttons that will fail. It is not a security control, because curl never loads the page. Send the request Alice’s browser would never send, with the header she was never given:

curl -s -w " HTTP %{http_code}\n" -X DELETE \
  -H "Authorization: Bearer tok_alice" -H "X-Role: admin" \
  localhost:3000/invoices/1002

The broken server deletes Bob’s invoice on Alice’s word:

{"deleted":1002,"left":1} HTTP 200

The fixed server ignores the header completely, and Dana can still do the job:

{"error":"admin only"} HTTP 403
{"deleted":1002,"left":1} HTTP 200

The rule to take away: a request is a list of things the user is claiming, not a list of facts. Headers, hidden form fields, JSON bodies, and cookies your own JavaScript set are all under the caller’s control. The only trustworthy input to an authorization decision is state the server looked up itself.

Common Mistakes and Troubleshooting

Checking the role in the frontend router. Hiding an admin page in a single-page application stops the menu item from appearing. It does not stop anyone from calling the API the page would have called. Every route the hidden page uses needs its own server-side check.

Assuming the identifier is safe because it is a UUID. A universally unique identifier is hard to guess, but identifiers leak constantly: shared links, support tickets, referrer headers, exported CSV files. Unguessable is not the same as unauthorized.

Only checking on read. GET /invoices/1002 is usually the first route anybody protects, and PATCH /invoices/1002 is the one nobody remembers. Write and delete routes are worth more to an attacker. Go through every method on every path.

Filtering the list but not the item. GET /invoices correctly returns only Alice’s invoices, so the list page looks fine. GET /invoices/:id was written separately and never got the same filter. Test the detail route on its own with a foreign identifier.

Returning 403 for records that are not yours. It leaks which records exist. Use 404 for a record the caller may not see, and keep 403 for a function they may not call.

Trusting a role that arrived in the token. If the client can obtain a token whose contents it influences, then the role in that token is client input. Signed self-contained tokens are the topic of the next part, and they only help when the signature is actually verified.

Best Practices

Deny by default. Write the middleware so a route with no rule attached is refused, not allowed. A new route added on a busy Friday should fail loudly, not quietly serve everybody.

Put ownership in the query, not after it. WHERE id = $1 AND owner_id = $2 cannot be forgotten by a later return statement, and it costs nothing extra in the database.

Derive identity once, in one place. One hook that turns a token into a session object, and every handler reads from that object. If a handler reads req.headers to decide anything, that is the bug.

Test authorization from the attacker’s seat. For each route, write a test that calls it with a valid session belonging to the wrong user and asserts 404 or 403. These tests are short, and they are the only ones that catch this class of bug.

Keep the rules document in the same pull request as the route. A document updated once a quarter is a document nobody trusts.

Log denied requests with the user, the route, and the record. A member repeatedly asking for identifiers they do not own is one of the clearest attack signals you will ever get.

Conclusion

Chapter V8 is four requirements and they close in order: write down who may do what, enforce it for functions, enforce it for records, and enforce it on the server. The three code fixes in this article are one line each, which is the point. Authorization bugs are almost never hard to fix, they are just easy to leave out, and nothing in a test suite complains when they are missing.

You now have a docs/authorization.md your reviewers can read, a role check that answers “may they” separately from “who are they”, a lookup that cannot return another user’s record, and a delete route that ignores anything the caller claims about themselves. Mark V8.1.1, V8.2.1, V8.2.2, and V8.3.1 as passed in your own record.

Part 11 covers chapter V9 Self-contained Tokens: what has to be true before you believe anything inside a JSON Web Token, including the classic forgery that works when a server accepts the algorithm the token asks for.

Requirement text quoted from the OWASP Application Security Verification Standard 5.0.0, used under CC BY-SA 4.0.

All tutorials →

Latest Tutorials

Support this site