Hiding a button isn't access control, so the check goes on the write and the menu is a courtesy
An admin foundation with authentication, roles and a real list screen already in it, built so the permission check sits on the action itself and not on the menu item that leads to it.
How the work was scoped
- Industry
- Professional Services
- Duration
- 6 weeks
- Cooperation model
- Fixed price
Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.
What was there, and what replaced it
Their admins looked secure and weren't. Menus were hidden by role and the routes behind them were open, so anyone who knew a URL could reach a page they had no permission for. It had happened twice, both times found by a customer before any test caught it.
Both incidents had the same cause: the menu was the access control. Hiding a link is a suggestion, and the route behind it had no check at all. The list screens carried a related fault. Client-side paging sends every row to the browser and draws a subset, so a table intended to show one department's records had been shipping the whole table.
Put the check where the work happens. Every write re-checks the permission regardless of how the request arrived, and the hidden menu is presentation on top of that, never the mechanism. A list screen that pages, sorts and filters on the server comes with it, because the hand-rolled version is where the second class of bug lives.
What we inherited
The team starts every internal tool from scratch and every one of them rebuilds the same three things. We built the foundation once: authentication with the full recovery flow, roles and permissions, a list screen that pages on the server, and notifications.
A team starting several internal tools a year, each rebuilding authentication, roles and list screens from scratch. The engagement was commissioned after the second occasion on which a customer discovered that a hidden menu item's route was open: a page reachable by anyone who knew the URL, in a tool whose menus implied it wasn't.
Full-Stack Engineering
What we kept, and what we cut
- 01
Re-checked permissions on every action, with no reliance on a hidden menu
The check moved to the action after two incidents in which a customer found an open route behind a hidden menu, both before anyone internal did.
- 02
Made the list screen page and sort server-side, since client-side paging leaks the rows it hides
Paging, sorting and search resolve in the database, because client-side paging ships the rows it pretends to hide. That's a disclosure as well as a performance fault.
- 03
Shipped the whole authentication recovery flow, because half of one is what gets built under deadline
The full recovery flow ships with the foundation, since under deadline only the login screen ever gets finished and the rest is promised for later.
- 04
Kept roles editable without code, so a new role doesn't need a deployment
Roles are records edited in the interface with permissions declared in code, so a reorganization doesn't require a release and the vocabulary stays fixed.
- 05
Wrote the permission tests into the foundation, so no project has to start them from zero
The permission tests ship with the foundation, so each project inherits a suite asserting what each role can and can't do.
Checks on the action
Permissions are re-checked on every write, so reaching a route directly is refused the same way a hidden menu would have refused it.
Every write re-checks the permission on the action itself, regardless of how the request arrived, so reaching a route directly is refused exactly as a hidden menu would have refused it. The hidden menu is presentation layered on top of that check. Treating a missing button as authorization is the mistake this foundation exists to stop repeating.
- Permission re-checked on the action, whatever the entry point
- Hidden menus are presentation, never the mechanism
- Direct route access refused identically
A direct route, refused: a Viewer types a product's edit URL and gets a 403, because the one permission on the action was checked. The list never drew the button, every other way in is refused the same way, and the refusal is in the audit log.
The server-paged list: a catalog editor searching their own department, with the request, the MySQL query and the response beside the table. The server returns the ten rows on screen and counts only the rows that role may see, so no rows are sent to be hidden.
Server-side lists
Paging, sorting and search happen on the server. Client-side paging ships the rows it pretends to hide.
Paging, sorting and searching all happen in the database through server-side DataTables. Client-side paging ships every row it pretends to hide, which is a performance problem at ten thousand records and a disclosure problem at any number: the rows a user shouldn't see were being sent to their browser and simply not drawn.
- Paging, sorting and search resolved in the database
- Client-side paging ships the rows it pretends to hide
- Both a performance and a disclosure fix
The full recovery flow
The full recovery flow: all six authentication flows (sign-up, verification, sign-in, reset, confirmation and recovery) with their routes, emails and tests, beside the reset page a locked-out user sees and the email it sends.
Verification, reset and confirmation all present from the start, because under deadline only login gets finished.
Email verification, password reset and confirmation are all present from the start, because under deadline only the login screen ever gets finished and the recovery paths get promised for later. Building them into the foundation means every project inherits a complete flow, instead of discovering the gap when a user is locked out.
- Verification, reset and confirmation present from day one
- Under deadline only login gets finished, so none of it is left until then
- Every project inherits the complete flow
A role's permission set, edited as a grid: the Returns desk role gaining one permission from the vocabulary in code, marked unsaved until it is saved, then live on its members' next request with no deployment.
Roles without deployment
A new role is a record, never a release.
A role is a record with a set of permissions, created and edited in the interface, so responding to a reorganization doesn't require a release. Permissions themselves are declared in code and seeded, which keeps the vocabulary fixed while letting the combinations be arranged by whoever runs the organization.
- Roles are records, created without a deployment
- Permissions declared in code, so the vocabulary stays fixed
- Combinations arranged by the organization, without engineering
Permission tests included
The foundation ships its own tests, so each project inherits them instead of writing them last.
The foundation ships with its own permission tests, so each project inherits a suite asserting that a role can do what it should and can't do what it shouldn't. Written last, these tests are written badly or not at all. Written into the starter, every project begins with the checks that matter most already passing and already meaningful.
- Permission tests ship with the foundation
- Every project inherits them instead of writing them last
- The checks that matter most pass from the first commit
The permission tests that ship with the foundation, run in a new project: every write route declares one permission, direct requests are refused like hidden menu items, lists return only permitted rows, and roles and the auth flows behave as built.
Phase by phase
Phase 1: Authentication
The Whole Flow, Not Half
Built registration, login, email verification, password reset, password confirmation and session handling. Each one is small; they're here because under deadline only the first two get built.
- Login & Register
- Verification
- Password Recovery
Phase 2: Roles And Permissions
Checked On The Action
Built roles with editable permissions and put the check on every write. A route reached directly is refused exactly as a hidden menu item would have been.
- Roles
- Permissions
- Action-Level Checks
Phase 3: The List Screen
Paged On The Server
Built the list screen with server-side paging, sorting and search. Client-side paging sends every row and hides most of them, which is a data leak wearing a table.
- Server-Side Paging
- Sorting
- Search
Phase 4: A Worked Example
Products And Categories
Built one real CRUD area on top of the foundation, so a new project starts by copying something that works instead of reading a README.
- Products CRUD
- Categories
- Validation
Phase 5: Notifications
In-App And Email
Built notifications once, because every project needs them, every project adds them late, and every project's version behaves differently.
- In-App Notifications
- Read State
The audit log: every write, with the permission that was checked at the moment it happened, who made it, how they reached it and whether it was allowed or refused.
After the rebuild
1, always
Checks per write
0
Rows sent to hide
None
Roles needing a deployment
6
Auth flows shipped
Checks per write is one, always, regardless of entry point. Rows sent to hide is zero, because paging is resolved in the database. Roles needing a deployment is none. Auth flows shipped is a count of the complete set: sign-up, verification, sign-in, reset, confirmation and recovery.
Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.
What the rebuild taught us
A hidden menu is a suggestion. The permission check has to be on the action or it isn't a check.
A hidden menu is a suggestion, and both incidents were the same discovery: the suggestion was all there had ever been between a URL and the page.
Client-side paging sends every row to the browser, which makes a table into a disclosure.
Client-side paging turns a table into a disclosure, and the fault is invisible in the interface because the rows are present and simply not drawn.
Authentication recovery flows are the first thing cut under deadline and the first thing needed in production.
Both of the team's previous tools had shipped with login only, and both needed a manual password reset in production within the first month.
Ship the foundation's tests with the foundation, or every project rewrites them badly and late.
Tests written per project get written last or never; shipped with the foundation, every project starts with the checks that matter already passing.
How we worked alongside the team
The scope came from the two incident reports, not a wish list. Each thing in the foundation traces to something that had already gone wrong, which kept it to six weeks. A starter built from wishes would still be in progress.
Six weeks, fixed, delivered as a foundation projects start from, not a library they depend on. The permission tests ship with it, which was the decision that mattered: written per project they get written last, badly, or not at all, and this foundation exists precisely because the same things kept being rebuilt under deadline.
One list, three roles
The server decides which rows leave. The menu only decides what is drawn.
The same product list requested as an administrator, a catalog editor and a viewer. The row count changes in the query, never in the browser; the same typed URL is allowed or refused by one permission; and each role’s writes land in the audit log with the permission that was checked. Switch tabs, or use the arrow keys once one is focused.
- Request arrives
- products.view checked on the action
- Query scoped in MySQL
- One page returned
- Rows permitted
- 2,418
- Rows sent
- 10
- Sent to hide
- 0
- Steel mixing bowl set, 3 pcKitchenware
- Linen duvet cover, queenTextiles
- Galvanized watering can 2 galGarden
- Brass swing-arm wall sconceLighting
- Stackable pantry bins, 4 pkStorage
Five of the ten rows shown. A client-paged table would have been sent all 2,418 and drawn ten.
Why the count changes where it does: paging, sorting and search resolve in the database, so the role’s scope is part of the query and the browser only ever receives the page it draws. Steps are paced for reading, not measured.
From any way in to the one page a browser draws
The check sits where the work happens and the list is resolved where the data lives. The menu is drawn last, on top of both, and decides nothing.
- 01 · TriggerAny request to an actionA menu click, a typed URL and a replayed form arrive the same way. How the request got here carries no authority.
- 02 · CheckPermission on the actionEvery write re-checks its permission regardless of entry point, so a direct route is refused exactly as a hidden menu would have refused it.
- 03 · EngineServer-side DataTablesPaging, sorting and search resolve in the database, so only the page being drawn leaves it.
- 04 · StateRoles as records in MySQLA role is a record edited in the interface; permissions are declared in code and seeded, so a reorganisation needs no release.
- 05 · DeliveryBlade views and notificationsThe browser receives no rows it won't draw, and the menu hides what a role lacks as presentation on top of the check.
What a role can reach
Access control on the action, beneath the menu
A URL isn't a way around a role
Every write re-checks its permission on the action, whatever the entry point. A route reached directly is refused exactly as the hidden menu item would have been, and the permission tests that assert it ship with the foundation.
Nothing is sent just to be hidden
Paging, sorting and search resolve in the database. Client-side paging sends every row and draws a subset; here the rows a user shouldn't see never leave the server.
A role change isn't a release
Roles are records created and edited in the interface. Permissions stay declared in code and seeded, so the vocabulary is fixed while whoever runs the organization arranges the combinations.
Is a hidden menu all that stands between your staff and the pages they shouldn’t reach? Scope your build in 3 minutes.
Scope your buildNearby engagements
AI & AutomationA private legal assistant grounded in verified precedents
A private knowledge assistant that searches internal case files and precedents, providing cited answers legal teams can verify in seconds.
Legal & Law Firms · 14 weeks
Product DesignAn onboarding flow that guides trial users to value
A redesigned SaaS trial onboarding experience with progressive checklists, sample data, and inline guidance that turns signups into active subscribers.
Professional Services · 10 weeks
Product DesignA design system that brought speed and consistency to 4 product teams
A token-based design system in Figma and React that eliminated component duplication across 4 product squads and cut the time from design handoff to merged frontend.
Professional Services · 14 weeks
Let's talk
Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.














