Back to Blog
Backend Security

5 Backend Invariants Every SaaS Should Enforce

The rules that must always hold true in your backend. When these invariants are violated, you get incidents. Here's what to check and why it matters.

ST
SecurityChecks Team
Engineering
January 3, 20265 min read
invariantssecurityauthorizationwebhooksmulti-tenant

Every backend has rules that must always hold true. Not style preferences. Not best practices. Rules that, when broken, cause production incidents.

These are invariants — the architectural assumptions your codebase depends on.

The tricky part? They're rarely documented. They live in the heads of your senior engineers. And they're easy to violate when you're moving fast.

Here are 5 invariants we see violated constantly — and how to enforce them.

1. Service-Layer Authorization

The Invariant: "Every service method must verify the caller is authorized."

The Question: "What happens when a background job calls this directly?"

It's common to add auth middleware to routes. That's good. But service methods can be called from background jobs, webhooks, and internal APIs — all of which bypass route middleware.

// Violation: No auth check in service
router.post('/billing/charge', authMiddleware, async (req) => {
  await billingService.charge(req.user.id, req.body.amount);
});

// billingService.charge() has no auth check
// A background job or webhook can call it directly

The fix is to enforce authorization at the service layer:

// Invariant satisfied
class BillingService {
  async charge(callerId: string, userId: string, amount: number) {
    await this.authorize(callerId, 'billing:charge', userId);
    // ...process payment
  }
}

This ensures authorization happens no matter how the function is called.

2. Webhook Idempotency

The Invariant: "Every webhook handler must be safe to call multiple times with the same payload."

The Question: "What happens when Stripe retries this three times?"

Webhooks get retried. If your handler creates a subscription every time it receives an event, you'll double-charge customers.

// Violation: No idempotency check
app.post('/webhooks/stripe', async (req) => {
  const event = req.body;
  if (event.type === 'invoice.paid') {
    await createSubscription(event.data.customer); // Runs every time!
  }
});

The fix is idempotency:

// Invariant satisfied
app.post('/webhooks/stripe', async (req) => {
  const event = req.body;

  // Check if we already processed this event
  if (await isEventProcessed(event.id)) return;

  if (event.type === 'invoice.paid') {
    await createSubscription(event.data.customer);
    await markEventProcessed(event.id);
  }
});

Every webhook handler should check for duplicate events before processing.

3. Transaction Side Effects

The Invariant: "External side effects must happen after commit, not during."

The Question: "Did we send a confirmation email before the transaction committed?"

It's tempting to put everything inside a transaction. But if the transaction rolls back, any emails sent, API calls made, or jobs queued have already happened.

// Violation: Side effects inside transaction
await prisma.$transaction(async (tx) => {
  const order = await tx.order.create({ data: orderData });
  await sendConfirmationEmail(user.email, order); // Sent even if tx fails!
  await updateInventory(order.items);
});

The fix is to defer side effects until after commit:

// Invariant satisfied
const order = await prisma.$transaction(async (tx) => {
  await updateInventory(order.items);
  return tx.order.create({ data: orderData });
});

// Side effects after commit
await sendConfirmationEmail(user.email, order);

External effects should only happen after you're sure the data is committed.

4. Cache Invalidation on Auth Changes

The Invariant: "Caches must be invalidated when permissions change."

The Question: "When permissions change, does the cache know?"

Caching permissions is great for performance. But if you don't invalidate on auth changes, revoked users retain access until the cache TTL expires.

// Violation: No cache invalidation
const getUserPermissions = cache(async (userId: string) => {
  return db.permissions.findMany({ where: { userId } });
}, { ttl: 3600 });

// When removing a user from a team...
await db.teamMember.delete({ where: { userId, teamId } });
// Cache still says they have access for up to an hour!

The fix is explicit invalidation:

// Invariant satisfied
async function removeFromTeam(userId: string, teamId: string) {
  await db.teamMember.delete({ where: { userId, teamId } });
  await invalidateUserPermissionsCache(userId);
}

Every mutation that affects permissions should invalidate relevant caches.

5. Immediate Membership Revocation

The Invariant: "Access revocation must be immediate, not eventually consistent."

The Question: "If I remove someone from a team right now, can they still access team resources?"

Membership is often checked against a cached or denormalized data source. When you revoke access, the user might still have access until the cache expires.

// Violation: Delayed revocation
async function getTeamDocs(userId: string, teamId: string) {
  const membership = await getCachedMembership(userId, teamId);
  if (!membership) throw new ForbiddenError();
  return docs.find({ teamId });
}

// Removing someone doesn't immediately revoke access
await db.teamMember.delete({ where: { userId, teamId } });
// getCachedMembership still returns true for cached TTL

The fix is immediate propagation:

// Invariant satisfied
async function removeFromTeam(userId: string, teamId: string) {
  await db.teamMember.delete({ where: { userId, teamId } });
  await invalidateMembershipCache(userId, teamId);
  await invalidateTeamAccessCache(teamId);
  await terminateUserSessions(userId, teamId);
}

Access revocation should be immediate, not eventually consistent.

Why These Invariants Get Violated

These aren't obscure edge cases. They're violated constantly because:

  1. They're cross-cutting — They span multiple files and services
  2. They're implicit — They live in senior engineers' heads, not documentation
  3. They pass tests — Unit tests verify individual functions, not architectural constraints
  4. They pass code review — Reviewers are inconsistent and overloaded

The result: code that works in development but fails in production.

How to Enforce These Invariants

We built SecurityChecks specifically for this. One command:

npx @securitychecks/cli run

It scans your codebase for invariant violations: authorization gaps, non-idempotent handlers, transaction hazards, and cache invalidation issues.

No signup. Runs locally. Your code doesn't leave your machine.


Your backend has rules that must hold true. Document them. Enforce them. Or learn them the hard way in production.

Enjoyed this article?

Subscribe to get the latest security insights delivered to your inbox.