JWTs and session cookies have fixed expiry windows. If a subscription lapses between token issuances, the user retains premium access until their next login — potentially days or weeks. CWE-613 (Insufficient Session Expiration) and CWE-602 (Client-Side Enforcement of Server-Side Security) both apply. NIST AC-3 (Access Enforcement) requires that access decisions happen at the enforcement point, not at authentication time. Reading subscription status from the database on each request is the only way to ensure that a cancellation or failed payment takes effect immediately.
High because stale JWT claims allow canceled subscribers to retain paid access for the full token lifetime without any server-side re-verification.
Read subscription status from the database on every protected request, not from the session token. For performance, a short-lived server-side cache (60 seconds) is acceptable; caching in the JWT is not.
// middleware.ts or a shared access-check utility
const user = await db.user.findUnique({
where: { id: session.userId },
select: { subscription_status: true, plan: true }
})
if (!user || !['active', 'trialing'].includes(user.subscription_status)) {
return redirect('/billing/upgrade')
}
Also ensure the set of statuses that grant access (active, trialing, etc.) is identical across all enforcement points — middleware, API routes, Server Actions, and UI components. Inconsistent status lists are a FAIL even if each individual check is server-side.
ID: saas-billing.subscription-mgmt.subscription-server-verified
Severity: high
What to look for: Examine how feature access decisions are made. Look at middleware, API route guards, server components that fetch user data, and any isSubscribed() or hasAccess() utility functions. The critical question is: does the access check read subscription status from your own database (which is updated by verified webhooks), or does it trust a JWT claim, cookie value, or client-supplied header? Also check whether your database subscription status is re-synced with the provider's API periodically or at key checkpoints.
Pass criteria: Count every server-side access control checkpoint — at least 1 per protected route. All server-side access control decisions read subscription status from the application database (which is populated by verified webhook events or server-side API calls to the payment provider). No access decision trusts a client-supplied value for subscription status. Additionally, verify that subscription status condition logic (e.g., which statuses grant Pro access: active, trialing, past_due) is consistent across ALL Pro-gated entry points — UI components, API route handlers, middleware, and Server Actions. If the UI treats trialing as Pro but an API route only checks for active, this is a FAIL. The subscription gate logic should be defined once and referenced everywhere, not duplicated with inconsistent conditions.
Fail criteria: Access decisions read subscription status from a JWT claim or cookie that is set at login and not refreshed from the database. Subscription status is trusted from client-supplied parameters. Access checks run only on the client side with no server-side enforcement. Subscription status condition logic (which statuses grant access) is duplicated across entry points with inconsistent conditions — e.g., the UI grants access for trialing but an API route only checks for active.
Skip (N/A) when: No subscription tiers or gated features detected.
Detail on fail: "Middleware reads subscription_status from JWT without re-checking database — expired subscriptions retain access until token refresh" or "Feature gate in app/dashboard/page.tsx checks client-side context only, no server-side verification"
Remediation: JWTs have expiry windows — if a subscription lapses between token issuances, the user keeps access until their next login. Read subscription status from the database on every protected request:
// In middleware or a server-side access check
const user = await db.user.findUnique({
where: { id: session.userId },
select: { subscription_status: true, plan: true }
})
if (user?.subscription_status !== 'active') {
redirect('/billing/upgrade')
}
For performance, cache this result in a short-lived server-side cache (e.g., 60 seconds) rather than caching in the JWT.