Hiding Sidebar Links Is UX, Not Authorization
Hiding a sidebar item is a UX decision, not an authorization boundary. Layout checks go stale; the API must fail closed.
TL;DR
Hiding sidebar links isn't security since Next.js layouts don't recheck auth on navigation and typed URLs can bypass the UI. The fix belongs at the API layer, where the backend validates scopes and returns an empty list instead of a 403 to avoid leaking modules. Frontend caching keeps it efficient while tests ensure restricted URLs just show empty tables.
I was staring at the main layout file for the admin CMS, trying to figure out why my role-based sidebar didn't feel completely secure. I had just added a constant for the module slugs and a visibility helper to hide navigation links for modules a user shouldn't access.
The setup looked clean. If a user lacked the culture scope, the link simply didn't render in the DOM.
The Optimistic UI Trap
I initially assumed that hiding the link was enough. Next.js layouts preserve state and do not re-render on navigation [1], so I figured the initial layout auth check was a solid wall. If the link isn't there, you can't click it.
But layout-level auth checks in Next.js are stale by design; they don't re-run per navigation [8]. Hiding a sidebar item is purely a UX decision, not an authorization boundary.
If a user manually typed the module query parameter into the URL, my optimistic UI would do absolutely nothing to stop them. The client-side router would just load the page, and if the backend wasn't explicitly checking the scope on every data fetch, they'd see the content.
Failing Closed at the API
I needed defense in depth. The real boundary had to live at the data layer, far away from the React tree.
In the Go backend, I updated the scope file with a narrowing function. Then I changed the admin handler to strictly fail-closed. When the frontend asks for an out-of-scope module parameter, the API doesn't throw a 403 Forbidden.
// Nilai balik ok=false berarti module yang diminta di luar scope.
func NarrowModules(requested string, allowed []string) ([]string, bool) {
if requested == "" {
return allowed, true
}
if !In(allowed, requested) {
return nil, false
}
return []string{requested}, true
}Throwing a 403 is a mistake here. It leaks information by telling an attacker that the module actually exists. Instead, the API just returns an empty list. The frontend gets the exact same UX signal—an empty data table—but the backend exposes zero enumeration risk [2].
Caching the Allowed State
Back on the frontend, the editor still needs to consume these filtered categories efficiently.
I wrapped the filtering logic in a memoization hook so it caches the allowed modules between re-renders until the dependencies actually change [3]. I only reached for an effect hook in the specific spots where the editor needed to trigger side effects based on those filtered categories [7].
To prove it all worked, I wrote an end-to-end test for the navigation. It verifies that the sidebar correctly renders the ARIA navigation landmark with only the permitted links, and that direct URL manipulation yields an empty table rather than an error page.
Security isn't about making the UI look restricted. It's about making sure the data layer refuses to answer questions it shouldn't.
Sources: