Skip to content

Dead Links in the Admin Sidebar: Menus That Always 403 Must Go

Adityo Guni Waluyo

An admin audit found two menu entries that always end in 403 and seven items still marked read-only. Both were removed, and the tests now assert the absence.

TL;DR

Audit found dead links in admin sidebar. Nav was not broken, just advertising inaccessible routes. Fix involved hiding links from department accounts.

The audit ticket said only "audit nav janggal", so I logged in with a scoped department account and clicked Menu Navigasi. The sidebar highlighted it, the route painted a shell, then a 403 landed on top. Pengaturan Situs did the same thing. Dead links in an admin sidebar are cheap to ignore and expensive to keep, and I had ignored these for weeks.

My first guess was a missing scope. Somewhere in a permission refactor a route had lost its department check, and the nav kept rendering the entry because the frontend still believed it existed. One look at the backend killed that theory.

The wrong guess: a missing scope

Both entries are deliberate. Every endpoint behind Menu Navigasi and Pengaturan Situs is wrapped in RequireGlobalBidang, so a department account cannot read there, let alone write. The nav was not broken. It was advertising a door that account was never given a key to.

That distinction decides the fix. A missing scope is a backend bug, patched at the endpoint. A global route with a visible menu entry is a frontend honesty problem, patched at the link. Commit ef43ed0 touches two files, 82 lines added and 31 removed, and stays entirely on the frontend side.

The same audit turned up a second lie running the other way. Seven of the eight function items were already scoped per department slice in the backend, yet each still wore a read-only eye marker, as if the owning department could not write to its own data.

What the change actually does

NavItem gained one flag, bidangSlice?: boolean. Seven items are flagged: Informasi, Ulasan, Layanan, Tipe Layanan, Paket Sewa, Saran & Kritik, Media. Persyaratan Dokumen stays unflagged because it is genuinely cross-cutting and has no owning department.

Alongside it, the layout holds a set of global-only hrefs, /admincms/menus and /admincms/settings, filtered inside buildNavGroups(bidang, permissions) for every account that is not the all-departments one. The all-departments account still sees both. For that account they are not dead, they are the job.

The transitional group "Fungsi — Lintas (Sementara)" was deleted whole. Only Persyaratan Dokumen survives, inside a small "Fungsi Lintas" group that renders when it is not empty. A group holding one item is still worth keeping when that item has no department. A group that exists because nobody finished the migration is not.

I considered a third option: keep the two entries visible but disabled, with a tooltip saying the page needs all-departments access. Rejected. A greyed-out nav item still teaches the wrong model of the account, and it still occupies a slot in the link list. Removing it says the same thing with less furniture.

Hiding is not enforcing

No security review was needed here, and the commit states it plainly: labels and grouping only. That sentence matters, because the easy misreading is that hiding a menu protects the route. It does not. Authorization is validated on every request, and a client-side check is never the decisive factor in granting or denying access [2]. The 403 the scoped account received was already the correct answer. The sidebar was the only component disagreeing with it.

Next.js documents the same boundary from the other direction. Layout-level auth checks do not re-render on navigation, so the session is not rechecked on each route change, and the check belongs close to the data source or the component being conditionally rendered [3]. A nav filter is presentation. Treat it as presentation and the two layers stop contradicting each other.

The cost of leaving it alone is not a security cost. A link is a promise, and every broken one chips at trust [1]. For a department staffer, "Menu Navigasi" reads as a task they are cleared to perform. The product says yes. The server says no. Correct backend enforcement repairs none of that first impression.

Accessibility makes the case harder to dodge. Assistive technology exposes a list of links, and the intent behind link purpose is to help users decide whether to follow a link before they follow it [4]. An entry that can only end in 403 fails that test on the most literal reading. It takes a slot, it costs a keystroke, and it teaches the user to distrust the whole list.

Tests that check what is gone

The old spec only proved presence. frontend/e2e/admin/modules-nav.spec.ts now asserts absence with toHaveCount(0) for Menu Navigasi and Pengaturan Situs on scoped accounts, plus the same zero count for the retired read-only accessible names. The open Fungsi Lintas group is verified through aria-expanded="true" on desktop, and through a plain link on mobile-chrome, where the group renders differently.

Absence assertions are the part I would keep if everything else were reverted. The regression behind this ticket was a link nobody removed, and no test in the suite would have failed if that link had multiplied. Presence-only specs are how a sidebar fills up with doors that lead nowhere.

What I am keeping from this: no sidebar entry may be a dead end for the account viewing it. A global route does not appear for a department account, and the backend keeps rejecting it if someone types the URL by hand. A scoped route appears without a read-only marker, unless it is actually read-only.

Sources:

  1. Nielsen Norman Group: A Link is a Promise
  2. OWASP Cheat Sheet Series: Authorization
  3. Next.js: How to implement authentication in Next.js
  4. W3C: Understanding Success Criterion 2.4.4 Link Purpose (In Context)

Related articles