Logging out cleared a variable in the browser, and the token stayed valid for eight more hours
This schema has a sessions table. It stores a hash of the issued token, the device it was issued to, an expiry, a last-seen timestamp and a revocation timestamp. There is a comment above it explaining that it exists for logout, revocation and audit, and that tokens are hashed server-side rather than stored.
That is the correct design, written down by somebody who knew what they were doing.
Nothing writes to it. Sign-in issues a token and inserts no row. The authentication middleware verifies the token’s signature and expiry and looks nothing up. There is no logout endpoint at all. Logging out, in the client, sets the stored token to null.
So a token that has been copied — from a shared machine, from a browser extension, from a support session where somebody pasted a request — remains valid until it expires on its own, which is eight hours by default. Signing out does not shorten that by a second. There is no operation anywhere in the product that can shorten it.
The table is the tell, again
What makes this worth writing about is not that the feature is missing. Sessions are commonly deferred. It is that the table exists, fully specified, with the revocation column already in it.
A reviewer looking at the schema concludes that revocation is handled. A reviewer looking at the authentication middleware sees a signature check and assumes the session lookup happens elsewhere, because there is a table for it. Neither person is being careless. The presence of well-designed storage is genuine evidence about intent and it is no evidence at all about behaviour.
Search the schema for tables no code writes to. It takes a few minutes with a grep per table name, and every hit is either dead weight or a promise the product is not keeping. In this codebase that search finds three: the sessions table, the aggregate table behind the dashboard’s headline figure, and the append-only history table that was meant to record changes to compliance rules. All three are described in comments as things a job or a flow will maintain. None of them are maintained.
The irony in the permission check
There is a decision in the same area that was made carefully, and the missing session work cancels it.
Permissions are not embedded in the token. Every gated request re-queries the database for the caller’s permissions. That costs an extra query on every request, and the reason to accept that cost is revocation: take a permission away and the very next request reflects it, with no waiting for a token to expire.
Except role assignments are not read on every request in a way that helps, because the thing users actually complain about is a role change not appearing. The client caches the permission list it received at sign-in and never refreshes it. So the server enforces the new rules immediately and the interface still offers the old ones, which produces the worst version of the problem: buttons that are visible and return 403.
The API is honest about it. The endpoint that changes a user’s roles returns a message in its response body telling the caller that the user must sign in again for the change to appear. A documented compromise, shipped as a string.
If you pay for per-request authorisation, spend the rest of it. Per-request permission resolution plus a client that caches for the session is the cost of the strict design with the behaviour of the lax one. Either put the permissions in the token and accept a bounded staleness that you state, or resolve them per request and give the client a way to be told they changed.
The lookup ignores the tenant
Underneath that sits a smaller and sharper problem.
The function that resolves a user’s permissions takes three arguments: the database, the user, and the organisation. It uses the first two. The organisation parameter is accepted, named with a leading underscore to mark it unused, passed by all four call sites, and never referenced in the query.
The query joins role assignments to permission grants by role, with no join to the roles table and no filter on which tenant a role belongs to. Roles in this schema can be system-wide or owned by a tenant. If a user were ever assigned a role belonging to a different tenant, its permissions would be granted.
The write path guards against that — the endpoint that assigns roles validates that the role is either system-wide or belongs to the caller’s organisation. Nothing guards the read path, and nothing in the database prevents it: the role assignment table is one of the few without the composite key that makes cross-tenant references impossible elsewhere in this schema.
So the protection is one validation in one endpoint. Any row arriving by another route — a seed, a migration, a fix applied by hand during an incident — is honoured.
The same query also does not exclude soft-deleted roles. A role that has been deleted disappears from the interface, because the endpoint that lists a user’s roles does filter deleted ones, and continues to grant its permissions, because this one does not. Two functions in the same file, reading the same data, with different opinions about what deleted means. The visible one says the role is gone. The enforcing one says it is not.
The role change that is not a transaction
One more, from the same endpoint. Changing a user’s roles is implemented as a delete of all their assignments followed by an insert of the new set. Two statements, not wrapped in a transaction.
A failure between them leaves the user with no roles at all. That is a locked-out user rather than an over-privileged one, which is the better direction to fail in, and it is still an outage for somebody caused by a partial write that a transaction would have prevented.
The same operation writes nothing to the audit log. Neither does the endpoint that resets a staff member’s portal password. In a product whose purpose is demonstrating compliance to an auditor, the two actions most likely to be asked about — who changed whose access, and who reset whose password — are the two that leave no trace.
The rules
A token you cannot revoke is a credential with a fixed lifetime. Decide that deliberately and make the lifetime match. Eight hours is a reasonable session length and an unreasonable time to be unable to withdraw access, and you only get to pick one of those without a session store.
Grep for tables nothing writes to. Storage is intent; queries are behaviour. Where they disagree, the schema will be believed and the code will be running.
One definition of deleted, in one place. If two functions disagree, the one that grants access is the one that matters.
Wrap replace-all writes in a transaction. Delete-then-insert is one operation in the user’s mind and two in the database, and the gap between them belongs to nobody.
The honest limit: none of this was found by anyone being attacked. It was found by reading the authentication path end to end, which took about an hour, and which I did not do until the product had been demonstrated to several people. That is the wrong order and I know it.