Auditing Upgrade and Downgrade Flows for Authorization Failures

Auditing Upgrade and Downgrade Flows for Authorization Failures

pr0h0
authorizationsecurityauditaccess-control
AI Usage (86%)

Why upgrade and downgrade flows are easy to get wrong

Upgrade and downgrade logic looks simple from the UI, but the real path usually runs through billing, entitlements, caches, and feature flags. That is where the bugs tend to live.

I usually see two failure modes:

  • the client assumes the plan change has already happened
  • the server accepts the transition without re-checking what the user is allowed to do

The impact is not always loud, but it is real. A free account may gain premium features after an upgrade request that was never fully verified, or a downgraded account may keep access longer than it should.

The trust boundary: UI state vs backend authorization

The mistake is treating plan state as a UI concern. If the button says “Pro,” that does not mean the backend should trust the client.

A good audit starts with a simple question: where is the authoritative source for the current user's tier?

If the answer is anything on the client side, the design is brittle. The backend should decide:

  • who the current user is
  • what plan they currently have
  • whether the requested transition is valid
  • whether the target resource is still allowed after the transition

That last check matters. A downgrade can invalidate access to data, exports, seats, API quota, or admin features. If the app only checks at login, stale access can hang around.

What to inspect first in a real audit

Start with the smallest set of requests that move the account from one state to another. You do not need the whole billing flow to find a bug.

Check the request parameters that change account tier

Look for fields like:

  • plan
  • tier
  • subscriptionId
  • role
  • accountType
  • entitlementId

Then ask whether the server validates them or just stores them. If the request contains tier: "pro" and the backend treats that as truth, the boundary is already weak.

Verify the backend re-evaluates entitlement on every transition

A transition should do more than update a row in the database. It should recalculate what the user can do after the change.

That means checking:

  • current user identity
  • current subscription state
  • payment or billing status
  • feature-specific authorization rules
  • whether the transition is allowed from the current state

If any of those are skipped, the app can drift into an inconsistent permission state.

Look for stale client state and cached permissions

Frontends often cache plan data in memory, local storage, or a query cache. That is fine for display, but dangerous for authorization decisions.

If the UI hides a premium button after downgrade but the API still accepts the old action, the bug is on the backend. If the API denies the action but the UI still shows it, the bug is in state invalidation. Both sides need to converge quickly.

Reproducing common authorization failures safely

Use a test account and a controlled environment. The goal is to show the permission boundary, not to break anything.

Upgrade flow: hidden endpoints and missing server checks

Sometimes the visible upgrade button is not the interesting part. The real issue is the API behind it.

I look for requests that can be replayed directly with a known account. If the server only checks that a request was made, not that the user completed payment or is eligible for the tier, the upgrade can be spoofed.

A safe test is to replay the request with the same authenticated session and confirm whether the backend changes the plan without verifying the billing result.

Downgrade flow: lingering access after a plan change

Downgrade bugs are often more subtle than upgrade bugs. The account is correctly marked as free, but access remains in one place:

  • old exports still download
  • admin pages remain reachable
  • API quota stays high
  • premium-only endpoints continue to work

This usually means authorization is checked at plan-change time, not at resource-access time.

Cross-account and role confusion cases

Watch for requests that include account IDs, workspace IDs, or role IDs. If the backend does not bind them to the current session, one user can act on another user's subscription state.

The common pattern is a request that says “change plan for account X,” while the server trusts X without proving the caller owns X.

JavaScript test cases that catch the bug early

Minimal request replay with fetch

async function changePlan(sessionCookie, body) {
  const res = await fetch("https://app.test/api/account/plan", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Cookie: sessionCookie
    },
    body: JSON.stringify(body)
  });

  return {
    status: res.status,
    text: await res.text()
  };
}

const result = await changePlan("session=test-user", {
  plan: "pro",
  accountId: "acct_123"
});

console.log(result);

This kind of replay is useful because it removes the UI from the equation. If the request works outside the button flow, the authorization check is probably too thin.

Assertions for current user, current plan, and target resource

function assertTransition({ actorId, targetAccountId, currentPlan, requestedPlan }) {
  if (!actorId) throw new Error("missing actor");
  if (!targetAccountId) throw new Error("missing target account");
  if (actorId !== targetAccountId) throw new Error("cross-account transition");
  if (currentPlan === requestedPlan) throw new Error("no-op transition");
}

assertTransition({
  actorId: "acct_123",
  targetAccountId: "acct_123",
  currentPlan: "free",
  requestedPlan: "pro"
});

This is not production authorization code, but it is a useful test harness. It makes the failure mode obvious before it reaches the service layer.

Fixing the failure without breaking the product

Enforce authorization in the backend service layer

Do not rely on controllers, route guards, or UI checks alone. The service that updates entitlements should verify the caller and the target resource every time.

The clean rule is simple: if the service changes access, it owns the authorization check.

Recalculate permissions after every plan transition

After an upgrade or downgrade, rebuild the effective permission set from the current source of truth. Do not keep old permissions around because they are “probably still fine.”

That means invalidating:

  • session-side feature caches
  • entitlement caches
  • role lookups derived from the old plan
  • queued jobs that assume the old tier

Add regression tests for both directions

One test for upgrade is not enough. You want:

  • a denied downgrade that should keep the user locked out
  • an allowed downgrade that removes premium access immediately
  • an upgrade that only succeeds after server-side verification
  • a cross-account transition that fails

That catches the cases where one direction was fixed and the other was forgotten.

Evidence to include in a report

A good report should show the boundary failure, not just the symptom.

Include:

ItemWhy it matters
Request and response pairProves the transition path
Current session identityShows who performed the action
Account or workspace ID usedExposes cross-account risk
Before/after entitlement stateConfirms the permission change
Reproduction notesHelps engineers validate the fix

If the downgrade leaves access behind, show the exact endpoint or feature that still works after the plan change.

Conclusion

Upgrade and downgrade bugs are usually authorization bugs wearing billing clothes. The UI may look correct, but the backend decides whether the change is real and whether access should still exist afterward.

If you audit these flows from the server outward, you will catch the broken assumptions early: trust the current session, recompute entitlements, and test both directions with replayed requests.

Share this post

More posts

Comments