Skip to content

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.

PlinthworkSearch products, users…3PRPriya RamanAdministrator
AdministrationRoles
Roles and permissions
Roles are records you edit here · permissions are declared in code and seeded
ExportNew role
Roles6
People with a role26
Permissions in code16
Needing a deploymentNone a role is a row

Roles

In order created · 6 records
RolePeoplePermissionsUpdated
AdministratorEverything, including roles and users216 of 16Aug 4, 2026
Catalog editorProducts and categories in their department57 of 16Aug 21, 2026
ViewerRead-only, in their department112 of 16Jul 30, 2026
SupportLooks up accounts, updates contact details44 of 16Sep 2, 2026
AuditorReads the audit log and who holds which role15 of 16Aug 11, 2026
Returns deskNew recordHandles returned stock and tells the team33 of 16Today, 9:12 AM

Permission vocabulary declared in code, seeded

Roles combine these; they cannot invent new ones
Productsviewcreateupdatedelete
Categoriesviewcreateupdatedelete
Usersviewinviteupdatedeactivate
Rolesviewmanage
Audit logview
Notificationssend

Notifications

3 unreadMark all read
AllUnreadEmail
Role created: Returns desk3 permissions from the vocabulary. Live on the next request.In-app·9:12 AMUnread
Write refused: users.deactivateHana Kim (Support) reached a route by typed URL.In-app·9:06 AMUnread
Email verifiedMarcus Bell confirmed his address and can sign in.In-app and email·8:58 AMUnread
Password reset completedHana Kim set a new password from the emailed link.In-app and email·8:31 AMRead
Category renamed: BakewareDana Whitfield, Kitchenware.In-app·YesterdayRead
New sign-up awaiting a roleMarcus Bell registered and was sent a verification email.In-app and email·YesterdayRead
Read state is kept per person, whichever channel it was opened from

How the work was scoped

Industry
Professional Services
Duration
6 weeks
Cooperation model
Fixed price
Services
Platform engineeringFoundationsInternal tooling
Integrations
Email deliveryError trackingScheduled backupsSession storage
Technologies
LaravelMySQLBladeServer-side DataTablesBootstrap
Team
1 Project lead1 Backend engineer1 Full-stack engineer

Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.

Before and after

What was there, and what replaced it

What was there

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.

What replaced it

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.

Introduction

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

01

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.

What shipped
  • Permission re-checked on the action, whatever the entry point
  • Hidden menus are presentation, never the mechanism
  • Direct route access refused identically
PlinthworkSearch products, users…LOLuis OrtegaViewer
Products#2291Edit
Not permitted
Reached by typed URL · /admin/products/2291/edit
403You don’t have permission to edit this productEditing Galvanized watering can 2 gal needs products.update. Your role, Viewer, does not hold it, and it makes no difference that you arrived without the menu.Back to productsAsk an administrator

The same product, as the list draws it for Viewer

No edit or delete buttons are drawn. That's presentation: the request above was refused by the check on the action, not by the missing button.
SKUNamePriceActions
GD-50377Cedar raised bed kit 4 × 8 ft$129.00View only
GD-50361Galvanized watering can 2 gal$24.00View only
GD-50344Bamboo plant stakes, 25 pk$9.50View only

The check on this request

10:04:31 AM
RequestGET /admin/products/2291/edit
Entry pointTyped URL · no link, no referring page
Signed in asLuis Ortega · Viewer · Garden
Permission checkedproducts.update exactly one, on the action
Viewer holdsproducts.viewcategories.view
ResultRefused · 403nothing rendered, nothing written

Every way in, one check

All refused the same way
Entry pointRequestPermissionResult
Edit button on the listGET /admin/products/2291/editproducts.update403
Typed URL or bookmarkGET /admin/products/2291/editproducts.update403
Replayed formPUT /admin/products/2291products.update403
Inline price editPATCH /admin/products/2291/priceproducts.update403
Delete from the listDELETE /admin/products/2291products.delete403
Audit log10:04:31 AMLuis Ortegaproducts.updateTyped URLRefused
On screen

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.

PlinthworkSearch products, users…2DWDana WhitfieldCatalog editor
KitchenwareProducts
Products
612 products in Kitchenware · paged, sorted and searched on the server
Export this pageNew product
steelCategory: allStatus: allShow 10 per page
SKUNameCategoryPriceStockStatusUpdated
KW-30418Steel mixing bowl set, 3 pcBakeware$34.00118ActiveSep 16, 4:52 PM
KW-30377Stainless steel colander 24 cmPrep$22.5064ActiveSep 16, 2:10 PM
KW-30352Brushed steel utensil holderStorage$18.000DraftSep 16, 11:38 AM
KW-30331Carbon steel wok 14 inCookware$49.0037ActiveSep 15, 5:21 PM
KW-30306Steel wire cooling rackBakeware$14.00205ActiveSep 15, 3:04 PM
KW-30279Stainless steel measuring cupsPrep$16.50142ActiveSep 15, 9:47 AM
KW-30254Steel-core chef's knife 8 inCutlery$68.0023ActiveSep 14, 4:15 PM
KW-30229Stainless steel stockpot 8 qtCookware$59.0041ActiveSep 14, 1:30 PM
KW-30211Steel pan organizer rackStorage$27.0076DraftSep 12, 10:02 AM
KW-30186Stainless steel oil canPrep$12.00158ActiveSep 11, 3:44 PM
Showing 11–20 of 47 (filtered from 612)12345

This page, on the server

draw 7
RequestGET /admin/products/datastart=10 & length=10order[0]=updated_at, descsearch[value]=steelchecked: products.view
Query, in MySQLselect … from productswhere department_id = 3and name like '%steel%'order by updated_at desclimit 10 offset 10
ResponserecordsTotal612Kitchenware onlyrecordsFiltered47matching "steel"data10 rowsthe page on screen
0Rows sent to hideEvery row that leaves the database is drawn.
On screen

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.

02

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.

What shipped
  • 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
PlinthworkSearch products, users…3PRPriya RamanAdministrator
SettingsAuthentication
Authentication
The whole flow ships with the foundation, well beyond the sign-in screen
Preview emails
What a locked-out user sees/forgot-password
PlinthworkReset your passwordEnter the email you sign in with and we’ll send a link to choose a new password.Emailhana.kim@example.comEmail me a reset linkBack to sign in

Reset your password

Email template
From "Plinthwork" · to hana.kim@example.comHi Hana,Someone asked to reset the password on your account. The link works once, and your current password stops working when you choose a new one.Choose a new passwordIf this wasn’t you, ignore this email.

Authentication flows

6 of 6 shipped
#FlowRoutesEmailTests
1Sign-upCreates the user with no role until one is assignedGET · POST /registerWelcomePassing
2Email verificationSigned link; unverified users stop at the notice pageGET /email/verify/{id}/{hash}Verify your emailPassing
3Sign-inSession regenerated on success, remember-me optionalGET · POST /loginNonePassing
4Password resetSingle-use token sent by email; the old password stops workingGET · POST /forgot-password, /reset-passwordReset your passwordPassing
5Password confirmationAsked again before roles, users and security settingsGET · POST /confirm-passwordNonePassing
6Account recoverySigns out every other session once access is backPOST /recovery, /logout-other-devicesYour account was recoveredPassing
SessionsRegenerated on sign-inSensitive pagesPassword confirmed againUnverified usersStop at the verify notice
03

The full recovery flow

On screen

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.

What shipped
  • 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
PlinthworkSearch products, users…3PRPriya RamanAdministrator
AdministrationRolesReturns desk
Returns desk
A role is a record: saved here, live on its members' next request, no deployment
CancelSave role
NameReturns deskDescriptionHandles returned stock and tells the teamCreatedToday, 9:12 AM · by Priya Raman

Permissions

16 declared in code · 4 selected
Resourceviewcreateupdatedeleteinvitedeactivatemanagesend
Productsproducts.*Unsaved————
Categoriescategories.*————
Usersusers.*————
Rolesroles.*——————
Audit logaudit.*———————
Notificationsnotifications.*———————
1 unsaved changeAddproducts.updateso the desk can correct stock on a returned product— not declared for this resource

Members

3 people
MBMarcus BellAdded todayACAisha ColemanAdded todayJPJonah PrattAdded today

When you save

Stored as dataThe role's permission set is a record, written by this form.Live on the next requestEach of the 3 members is checked against it next time they act.Checked under roles.manageSaving is itself a write, and the audit log records it.Vocabulary stays fixedThis grid combines permissions from code; it cannot invent one.
On screen

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.

04

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.

What shipped
  • Roles are records, created without a deployment
  • Permissions declared in code, so the vocabulary stays fixed
  • Combinations arranged by the organization, without engineering
05

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.

What shipped
  • 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
plinthwork — testsshipped with the foundation · inherited by every project
~/returns-tool $ php artisan test --testsuite=Permissions,Auth PASS Tests\Permissions\WriteRoutesTest ✓ every write route declares exactly one permission ✓ a typed URL is refused like the hidden menu item → 403 ✓ a replayed form is refused like the hidden menu item → 403 PASS Tests\Permissions\ProductsTest ✓ administrator can update a product ✓ catalog editor can update a product in their department ✓ catalog editor cannot update another department's product → 403 ✓ viewer cannot update a product → 403 ✓ viewer cannot delete a product → 403 PASS Tests\Permissions\ListScopeTest ✓ list pages, sorts and searches in the database ✓ response holds only rows the role may see ✓ recordsTotal counts only permitted rows PASS Tests\Permissions\RolesTest ✓ a role created in the interface applies on the next request ✓ only roles.manage can change a role → 403 ✓ a permission outside the seeded vocabulary is rejected PASS Tests\Auth\FlowsTest ✓ sign-up, verification, sign-in, reset, confirmation and recovery complete Tests: 15 passed
On screen

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.

Process

Phase by phase

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
    • Email
    • Read State
PlinthworkSearch products, users…GOGrace OkaforAuditor
AdministrationAudit log
Audit log
Every write, with the permission that was checked at the moment it happened
Export CSV
Thu, Sep 17, 2026All peopleAll permissionsAllowed and refused13 writes9 allowed4 refused
TimePersonWritePermission checkedEntry pointResult
10:04:31 AMLuis Ortega · ViewerEdit product #2291products.updateTyped URLRefused · 403
10:02:47 AMDana Whitfield · Catalog editorUpdate price, product #1236products.updateInline editAllowed
9:58:12 AMLuis Ortega · ViewerDelete product #2264products.deleteForm replayRefused · 403
9:55:40 AMMarcus Bell · Returns deskUpdate stock, product #1996products.updateButtonAllowed
9:41:06 AMPriya Raman · AdministratorUpdate role Returns desk, +products.updateroles.manageMenuAllowed
9:37:29 AMDana Whitfield · Catalog editorUpdate role Catalog editorroles.manageTyped URLRefused · 403
9:33:51 AMDana Whitfield · Catalog editorCreate category Enamelwarecategories.createButtonAllowed
9:21:18 AMHana Kim · SupportUpdate contact email, user #88users.updateButtonAllowed
9:14:02 AMPriya Raman · AdministratorAssign 3 users to Returns deskusers.updateMenuAllowed
9:12:44 AMPriya Raman · AdministratorCreate role Returns deskroles.manageMenuAllowed
9:06:15 AMHana Kim · SupportDeactivate user #71users.deactivateTyped URLRefused · 403
8:59:37 AMOmar Haddad · Catalog editorUpdate description, product #1139products.updateButtonAllowed
8:47:20 AMPriya Raman · AdministratorSend notification to Kitchenwarenotifications.sendMenuAllowed
One permission per write, recorded with the result. The entry point is noted and never changes the answer.
On screen

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.

The same list requestGET /admin/products/data?start=0&length=10
  1. Request arrives
  2. products.view checked on the action
  3. Query scoped in MySQL
  4. One page returned
Scope, in the queryno department clausePriya Raman · All departments
Rows permitted
2,418
Rows sent
10
Sent to hide
0
What the server sentShowing 1–10 of 2,418
  • 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.

Typed URLGET /admin/roles/6/editroles.manage 200 · allowedRoles is in the menu, and the check still runs.
One of its writes, in the audit logUpdate role Returns desk, +products.updateroles.manageAllowed9:41:06 AM · Menu

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.

Architecture

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.

  1. 01 · Trigger
    Any 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.
  2. 02 · Check
    Permission 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.
  3. 03 · Engine
    Server-side DataTablesPaging, sorting and search resolve in the database, so only the page being drawn leaves it.
  4. 04 · State
    Roles 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.
  5. 05 · Delivery
    Blade 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 build
Have a project?

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.