Billing-aware authorization
Gate features by subscription plan
Your app has plans: free, pro, enterprise. Features are tiered. Authorization considers not just role but plan.
Storing the plan
ALTER TABLE identities ADD COLUMN plan TEXT DEFAULT 'free';Or in metadata:
identity.metadata_public.plan = "pro"Updates from Stripe webhook.
Authz check
function canUseFeature(user: User, feature: string): boolean {
const featureRequirements = {
"advanced_reporting": ["pro", "enterprise"],
"api_access": ["pro", "enterprise"],
"sso": ["enterprise"],
"audit_log_export": ["enterprise"],
};
const required = featureRequirements[feature];
return required.includes(user.plan);
}In your app
{canUseFeature(user, "advanced_reporting") ? (
<AdvancedReportingPage />
) : (
<UpgradePrompt feature="advanced_reporting" />
)}Upgrade prompts
function UpgradePrompt({ feature }) {
return (
<div className="upgrade-banner">
<h3>Upgrade to access {feature}</h3>
<p>This feature is available on Pro and Enterprise plans.</p>
<Button href="/billing/upgrade?plan=pro">Upgrade to Pro</Button>
<Link href="/billing/plans">See all plans</Link>
</div>
);
}Friendly, leads to upgrade flow.
Trial logic
Free trial of paid features:
function canUseFeatureWithTrial(user: User, feature: string) {
if (canUseFeature(user, feature)) return true;
// Trial check
if (user.trial_active && featureRequirements[feature].includes("pro")) {
return true;
}
return false;
}Active trial → access to pro features.
Feature flags vs plan gates
These are different:
- Feature flag: gradual rollout (10% of users). Eventually 100%.
- Plan gate: permanent restriction based on subscription.
Don't conflate. Same code can use both:
const showAdvanced = featureFlag("new_reporting", user) && canUseFeature(user, "advanced_reporting");Backend enforcement
UI gating isn't enough. Enforce server-side:
app.get("/api/advanced-report", async (req, res) => {
const user = await getUser(req);
if (!canUseFeature(user, "advanced_reporting")) {
return res.status(403).json({ error: "plan_required", required: "pro" });
}
// ...
});Otherwise: power user just sends API requests directly.
Stripe webhook integration
app.post("/webhooks/stripe", async (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], WEBHOOK_SECRET);
if (event.type === "customer.subscription.updated") {
const sub = event.data.object;
const newPlan = mapStripeProductToPlan(sub.items.data[0].price.product);
const customer = await stripe.customers.retrieve(sub.customer as string);
const olympusId = (customer as Stripe.Customer).metadata.olympus_identity_id;
await kratos.adminPatch(olympusId, [
{ op: "replace", path: "/metadata_public/plan", value: newPlan }
]);
}
if (event.type === "customer.subscription.deleted") {
// Downgrade to free
const customer = await stripe.customers.retrieve(...);
await kratos.adminPatch(olympusId, [
{ op: "replace", path: "/metadata_public/plan", value: "free" }
]);
}
res.json({ received: true });
});Plan changes propagate within seconds.
Reading plan in JWT
For backend services that need to check, include in access token:
// Hera consent shaper
session.access_token = {
"https://your-app.com/plan": identity.metadata_public?.plan ?? "free",
...
};Backend:
const plan = req.user["https://your-app.com/plan"];Cache during the token's lifetime. Refresh tokens get fresh plan.
Grace period
When user downgrades from pro to free, they might lose access mid-session.
Options:
- Hard cut: features stop immediately.
- Grace period: 24h continued access.
- End of billing period: continue until period ends.
Most common: end of billing period (Stripe handles via cancel_at_period_end).
Lurking features
Some features should NEVER be exposed without proper plan, even temporarily:
// In React
if (!canUseFeature(user, "sso")) {
return null; // not even visible
}Don't show "upgrade to use SSO", for some it's confusing.
But: be careful with hidden features. Sometimes you want to advertise via "locked" UI:
<MenuItem disabled lockedBy="pro">
<LockIcon /> SSO
</MenuItem>Tooltip explains. User clicks → upgrade flow.
Enterprise overrides
For specific customers on custom contracts:
CREATE TABLE feature_overrides (
identity_id UUID,
feature TEXT,
granted BOOLEAN,
reason TEXT,
PRIMARY KEY (identity_id, feature)
);async function canUseFeature(user, feature) {
// Check override first
const override = await db`SELECT granted FROM feature_overrides WHERE identity_id = ${user.id} AND feature = ${feature}`.first();
if (override) return override.granted;
// Default by plan
return featureRequirements[feature].includes(user.plan);
}Custom deal: grant pro feature to specific free user.
Audit
audit({
event: "feature_access",
user: user.id,
feature,
allowed: canUseFeature(user, feature),
reason: ...,
});Track gated access for analytics:
- Most-requested locked features → consider including in lower plans.
- Most-used premium features → highlight in marketing.