Security Basics
6 min
A customer should never be able to change one value in a URL, request body or API call and see another customer’s data. Yet that is exactly how many live SaaS products expose invoices, profiles, projects, private messages and admin functions. The five signs of broken authorisation are often easy to miss during a fast launch, particularly when authentication is working and the interface appears to behave correctly.
Authentication answers whether someone is logged in. Authorisation answers what that person is allowed to do. A user can have a valid session, a correctly signed JWT and a familiar account screen, while still being able to read or change records that belong to someone else. That distinction is where account compromise, privacy incidents and fraudulent actions begin.
The five signs of broken authorisation
1. Changing an object ID returns another user’s record
This is the classic insecure direct object reference, or IDOR. Your application requests /api/invoices/1842, a user changes the number to 1843, and the server returns an invoice belonging to a different account. UUIDs make guessing harder, but they are not an access-control decision. IDs can leak through browser history, emails, logs, shared links or an earlier API response.
The real failure is usually a database query or API handler that fetches by record ID alone. It should also verify that the authenticated user has a relationship with that record, directly or through the correct organisation or workspace.
This problem is especially common in marketplace and multi-tenant products. A route that worked for the founder’s own test account may never have been tested with two separate organisations. Check every endpoint that receives an ID: documents, orders, exports, customer profiles, payment records, support tickets and uploaded files.
The fix belongs on the server and, where applicable, in the database policy. Do not rely on an unguessable URL, a hidden button or a frontend condition. A secure query scopes records to the current tenant and verifies the caller’s permitted role before returning or modifying anything.
2. Row-level security is disabled or too broad
Supabase makes it quick to put a database-backed product online. It also makes RLS configuration a critical part of the application’s authorisation model. If row-level security is disabled on a public-facing table, or a policy effectively permits all authenticated users to select, update or delete rows, your app can expose data even when its UI looks restricted.
A common weak policy checks only that auth.uid() exists. That means any signed-in user may qualify. Another frequent error compares the user ID against the wrong column, fails to account for organisation membership, or creates a permissive policy for development and leaves it in production.
Review policies table by table, then operation by operation. Reading a public catalogue may be intended. Reading every customer profile is not. Creating a record may be permitted, while changing its owner_id, organisation_id, payment status or approval state should not be. Updates deserve particular scrutiny because an overly broad update policy can let a user move records into a different tenant or grant themselves a higher role.
Test RLS with at least two ordinary accounts in separate organisations and one low-privilege member in the same organisation. Test direct API calls, not only the frontend. If a policy is difficult to state in one clear sentence, it is often doing too much.
3. The frontend is the only thing stopping a user
A hidden admin menu is not an authorisation control. Neither is disabling a button for users without the right role. Anyone can inspect frontend code, replay a request from browser developer tools, or call an exposed endpoint directly. AI-generated applications regularly contain polished role-based interfaces while their API routes accept the same action from any authenticated session.
Look for logic such as if (user.role === 'admin') in React components without a matching check in the API route, server action or edge function. Also look for endpoints that accept a role, isAdmin, userId or organisationId field from the client and treat it as trustworthy.
Every privileged operation needs an enforcement point that the client cannot bypass. For example, inviting users, issuing refunds, viewing audit logs, editing subscription status and exporting customer data should verify the session, load the caller’s current permissions from a trusted source and reject requests that do not qualify.
There is a trade-off here. Centralised middleware can reduce duplicated checks, but not every route has identical rules. A useful pattern is central session verification plus explicit, local permission checks for actions with business impact. That makes the decision visible in code review rather than hiding it behind a broad assumption that all logged-in users are equal.
4. A token claim is treated as permanent truth
JWTs are useful, but they are easy to overtrust. An application may accept an unsigned token, fail to validate its issuer or audience, accept an expired token, or use a role claim that no longer reflects reality. In less obvious cases, a user is demoted or removed from an organisation but can continue to act with the permissions embedded in an old session.
The correct approach depends on your identity provider and risk level. A short-lived, properly validated token may be suitable for many routine requests. High-impact actions, such as changing bank details or promoting an administrator, may require a fresh permission lookup or re-authentication. The important point is to make that choice deliberately.
Do not use client-provided token payloads as a substitute for verification. Validate signatures using the expected algorithm and trusted keys, enforce expiry, issuer and audience where supported, and avoid accepting multiple algorithms unless you have a specific reason. If roles are stored in JWT custom claims, define how role changes take effect and test that path.
A useful red flag is a support process that says a former employee or removed contractor may need to wait for their session to expire. That might be tolerable for a low-risk product with a short session lifetime. It is not a sensible answer for an app handling customer data, operational controls or payments.
5. Privileged credentials or server actions are reachable from the client
A Supabase service-role key, cloud credential or internal API secret placed in a JavaScript bundle can bypass the controls you expected RLS to provide. The same risk exists when a server endpoint uses elevated credentials but lets the browser choose which customer, account or transaction it should act on.
Service credentials should remain server-side and should be used narrowly. An endpoint using a service role to process a legitimate action must independently verify the user, tenant and business rule before it performs the privileged database operation. Elevated database access does not remove the need for application-level authorisation. It increases the impact of getting it wrong.
Payment flows are a high-priority example. A client should not be able to mark an invoice paid, apply a discount, alter a subscription tier or claim that a payment completed. Verify payment events through the provider’s signed webhook, then make state changes on the server. Apply the same discipline to password resets, email changes, file downloads and account deletion.
Repository history matters here. Removing a leaked key from the current code does not remove it from previous commits, build logs or deployed bundles. Rotate exposed credentials, identify what they could access and review related logs. Treat a service-role key in client code as an incident to contain, not just a linting issue to tidy up.
How to test authorisation without guessing
Authorisation testing is not about running a scanner and assuming the green output means every access rule is sound. Automated tools can identify exposed endpoints, insecure headers, leaked secrets and known patterns. Manual testing establishes whether a real user can cross a boundary they should not cross.
Start with a small test matrix: an anonymous visitor, a normal user in organisation A, a normal user in organisation B, a low-privilege member in organisation A and an administrator. For each meaningful API action, ask whether each identity should be able to read, create, update or delete the target resource. Then test the request directly with altered IDs, tenant fields and role-related inputs.
Keep the evidence. A useful finding names the affected route or file path, explains the exact request condition, states the severity and identifies the expected fix. For example: POST /api/projects/:id/members accepts any authenticated caller because the route checks for a session but never verifies project ownership. That gives an engineer a place to start and gives a founder a clear view of the operational risk.
If your app was assembled quickly with AI assistance, spend extra time reviewing repeated route patterns. One missing tenant check can be copied across ten handlers. One permissive RLS policy can expose every row in a table. The speed benefit of generated code is real; the assumption that repeated code has repeated security decisions is where teams get caught out.
HollowByte audits live applications and repositories for these failure modes, pairing automated analysis with direct checks of the paths an attacker would actually use. The goal is not a vague risk register. It is to identify the route, policy or configuration that needs changing and give your team a defined route to fix it.
The most useful next step is simple: choose one sensitive customer record in your live app and try to access it from a second, unrelated account. If that test is difficult to run, or you cannot explain which layer blocks it, you have found an authorisation question worth answering before your customers do.
Back to blog
