Skip to content
All posts

The Security Guide to Permission Revocation on Read Replicas

June 18, 2026·Read on Medium·

Laravel’s sticky reads help inside one request. Revocation bugs happen on the next one.

You remove a contractor from the finance team at 10:03:01. The admin screen confirms it, the row changed and the audit log exists. Everyone feels responsible and secure for about three seconds.

At 10:03:04, that same contractor refreshes an old invoice URL and the download still works.

That is not the kind of authorization bug people usually look for. There is no missing if statement, no public S3 bucket, no swaggering attacker chaining three obscure headers together. The policy check still runs. The code still asks the database whether this user should have access. The problem is uglier than that. Your security decision is reading from a place that has not caught up yet.

Permission revocation sounds like an auth problem. In distributed systems it is also a consistency problem. If your application writes permission changes to a primary database node but serves authorization reads from replicas, revocation becomes eventually consistent whether you intended it or not.

Which is fine, until it is not.

Authorization gets weird the moment reads split from writes

OWASP’s authorization guidance is straightforward: validate permissions on every request. That advice is correct. It is also incomplete for modern backends that separate read traffic from write traffic.

When you introduce read replicas, you create two timelines:

  • The primary timeline, where the revocation was already committed.
  • The replica timeline, where the old permission may still be visible for a short window.

If your policy check runs against the second timeline, “check on every request” does not save you. You are checking. You are just checking stale state.

This is why revocation bugs are so slippery. The code path looks disciplined:

  1. Admin removes a role or membership.
  2. Application writes the change.
  3. Next request runs the policy again.
  4. Policy allows access anyway because the replica has not applied the change yet.

No step looks absurd in isolation. The system still fails.

And that failure has a distinct security shape. It grants access after access was explicitly revoked. Not because the system forgot to ask, but because it asked a lagging witness.

The mental model most teams use is half right

The naive model goes like this: once the transaction commits, the system knows the user lost access. From there, every request is safe because the policy check is server-side and the database is the source of truth.

That model is only half wrong, which is what makes it dangerous.

Yes, the database is the source of truth. But in a replicated setup, “the database” is not a single point in time. It is a set of nodes converging toward the same truth at different speeds. MySQL’s own replication docs say the original mode is one-way asynchronous replication. Laravel’s database docs also make it easy to send reads to one host and writes to another. Those two facts are individually useful. Put them together carelessly and your revocation semantics change.

The security consequence is simple:

  • A permission grant can arrive late and feel annoying.
  • A permission revocation can arrive late and become an incident.

Those are not symmetrical outcomes.

Laravel makes read splitting easy, which is good news and bad news

Laravel documents read and write connections directly in config/database.php, including the optional sticky flag:

<?php

return [
'connections' => [
'mysql' => [
'driver' => 'mysql',
'read' => [
'host' => [
env('DB_READ_HOST_1'),
env('DB_READ_HOST_2'),
],
],
'write' => [
'host' => [
env('DB_WRITE_HOST'),
],
],
'sticky' => true,
'database' => env('DB_DATABASE'),
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
],
],
];

That feature exists for good reason. Most permission checks are tiny reads. Sending them to replicas keeps the primary free for mutations and heavier transactional work.

The trouble starts when teams assume sticky is broader than it is.

Laravel’s docs are explicit here: sticky keeps later reads on the write connection only after a write has already happened during the current request cycle. That helps with read-after-write inside a single request. It does not make the next request safe. It does not make another worker safe. It does not protect the queue consumer that wakes up 400 milliseconds later and reads from a replica. It definitely does not save a second browser tab.

That last point matters more than people think. Security bugs love crossing request boundaries.

Here is the failure mode in plain English

Imagine this Laravel flow:

  1. An admin removes a user’s billing_admin role.
  2. The role delete hits the primary successfully.
  3. The user immediately calls GET /invoices/2026–0441/download.
  4. Your policy middleware loads the user’s team membership from a replica.
  5. The replica still returns the old membership row.
  6. The file download is allowed.

No cache poisoning, no forged token, just replica lag.

If you want the ugly version, it gets worse when the permission check itself is technically correct but depends on derived data:

  • a cached role graph
  • a denormalized access table
  • a search index used for filtering
  • an async projection consumed by another service

Now your revocation window is no longer “replica lag.” It is the slowest part of the whole chain.

In practice, that means a useful operational rule:

Your real revocation deadline is the maximum of replica lag, cache TTL, queue delay and projection delay.

That is the number that decides whether a removed user still gets in.

Sticky reads solve the wrong half of the problem

This is the part teams miss because the word sounds comforting.

sticky fixes the developer experience problem where a request writes a row and then immediately reads the old value back. That is a same-request consistency aid. It is not a revocation strategy.

The difference is subtle:

  • Same-request consistency asks, “Can this request see what it just wrote?”
  • Revocation safety asks, “Can every later request stop seeing what we just removed?”

Those are different guarantees.

If your auth decision must be immediate, you need a primary-only read for that decision, or an architecture where the permission state embedded in the request is itself versioned and invalidated safely. Anything else is a performance optimization pretending to be a security boundary.

I have seen teams treat sticky like a magic word here. It is not magic. It is a request-local convenience flag.

The safer patterns are less elegant and much more reliable

There is no single perfect fix. There are, however, a few patterns that fail in predictable ways instead of surprising you later.

Pattern 1: Read authorization-critical state from the primary

This is the blunt instrument, and for high-risk actions it is often the correct one.

If a request decides whether a user may:

  • download regulated data
  • access an admin route
  • change billing settings
  • impersonate another user
  • see a tenant boundary

then the permission lookup should hit a primary or another strongly consistent source, not a lagging replica.

Here is a Laravel-style policy sample that forces the membership check onto the write connection:

<?php

namespace App\Policies;

use App\Models\Invoice;
use App\Models\TeamMembership;
use App\Models\User;
use Illuminate\Auth\Access\Response;

class InvoicePolicy
{
public function download(User $user, Invoice $invoice): Response
{
$membership = TeamMembership::onWriteConnection()
->where('user_id', $user->id)
->where('team_id', $invoice->team_id)
->first();

if (! $membership || $membership->revoked_at !== null) {
return Response::deny('Access has been revoked.');
}

return Response::allow();
}
}

This costs more than a replica read. Good. Expensive security decisions are usually cheaper than incidents.

Pattern 2: Split “browse” state from “authorize” state

Many systems make one giant query do everything. It fetches the page data, joins role data and decides access in the same read path. That is convenient until you want different consistency rules for each concern.

A better split looks like this:

  • Use a strong source to answer “may this user do this?”
  • Use replicas for the heavy page data after the answer is already no longer in doubt

That keeps primary pressure focused on the small part that actually carries the security risk.

Pattern 3: Version permission state and invalidate sessions or tokens

If your app issues long-lived sessions, API tokens, or signed capabilities, revocation has to beat whatever old state is already in circulation.

A common pattern is to store a permission version or authorization epoch on the user record. Every token or session carries the version it was issued against. On each request, the app compares the presented version with the current one from a strong source. If they differ, the session is rejected or reloaded.

For session-based Laravel apps, that can look like a middleware that reloads the user from the write connection and logs them out if the stored version no longer matches:

<?php

namespace App\Http\Middleware;

use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;

class EnsureFreshAuthorization
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();

if (! $user) {
return $next($request);
}

$freshUser = User::onWriteConnection()->find($user->id);

if (! $freshUser || $request->session()->get('authz_version') !== $freshUser->authz_version) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();

return redirect()->route('login');
}

return $next($request);
}
}

This is more operational work. It is also the first pattern on this list that scales cleanly across multiple services.

Pattern 4: Treat cache invalidation and replica lag as the same threat model

Teams often discuss them separately:

  • “The database is eventually consistent.”
  • “The permission cache expires in 60 seconds.”

From the attacker’s perspective, these are the same bug class. Both create a window where revoked access still works. Put them in the same design review, the same runbook and the same test plan.

Pick the fix based on blast radius, not purity

Here is the decision framework I would actually use:

Low-risk internal views

  • Replica reads are usually acceptable.
  • Short lag windows are annoying, not dangerous.
  • Measure the window anyway so you know what “acceptable” means.

Medium-risk product features

  • Primary reads for the policy check.
  • Replica reads for the bulk page payload.
  • Session or token versioning if access changes often.

High-risk actions

  • Primary or equivalently strong store for authorization state.
  • No cache-only permission decisions.
  • Immediate session invalidation on revocation where feasible.
  • Audit logging around revocation and post-revocation access attempts.

That is not a universal law. It is a sane default.

The Laravel-specific trap is assuming the framework owns the whole answer

Frameworks help with mechanics. They do not choose your security semantics for you.

Laravel gives you middleware, policies, guards, Sanctum and the sticky option. None of those decide whether revocation must be immediate for a given route. That is your system design job.

A few concrete Laravel checks are worth making:

  • Which routes use policies or gates that fetch permission state from the database?
  • Are those queries guaranteed to use a primary-backed connection?
  • Does any permission state get cached in Redis, in memory, or on a relation that can outlive the revocation event?
  • Do background jobs re-check access before handling delayed work?
  • Do signed URLs or exports remain valid after role removal?

Most teams can answer the first question. Far fewer can trace the exact connection, cache and queue path behind the second. That is usually where the trouble starts.

Test this before a user does

Revocation bugs are testable. You do not need a red team to find them.

Run this sequence in staging:

  1. Create a user with access to a sensitive action.
  2. Force the policy read path onto the same topology production uses.
  3. Revoke access on the primary.
  4. Immediately replay the old request from another session or another tab.
  5. Measure how long access continues to work.

If the answer is anything other than zero for a route that requires immediate revocation, you do not have a theoretical issue. You have a security bug with a stopwatch attached.

And test the annoying cases, not just the clean ones:

  • revocation during an active session
  • revocation while a queue job is already in flight
  • revocation followed by a signed URL fetch
  • revocation after the permission cache was warmed

Those edge cases are where the polite architecture diagrams stop helping.

The uncomfortable truth is that revocation is a consistency guarantee

A lot of security writing treats authorization as a pure logic problem. Sometimes it is. Sometimes the check is missing and the bug is obvious.

But once your app grows past one database node, one cache policy and one request path, revocation turns into a distributed systems question. The policy can be correct. The middleware can be in the right place. The audit log can show the exact second you removed access. None of that matters if the decision engine still reads yesterday’s answer for one more request.

That is the part worth remembering: a revoked permission that still works is not a small timing issue. It is broken access control with nicer wording.

Design the read path like it matters. It does.

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium
The Security Guide to Permission Revocation on Read Replicas — Hafiq Iqmal — Hafiq Iqmal