Stripe subscription gate
Restrict access to features based on Stripe subscription status
You sell a SaaS with paid tiers. Login should succeed for everyone, but feature access depends on subscription. Here's how to wire that with Olympus.
Architecture
User logs in via Olympus (Hera/Kratos)
↓ session granted
User hits feature endpoint
↓ your backend checks subscription
↓ allow or redirect to /pricingOlympus handles authentication (who they are). Your app handles authorization (what they can do).
Storing subscription state
Two options:
A: Store in your app DB
CREATE TABLE user_subscriptions (
identity_id UUID PRIMARY KEY,
stripe_customer_id TEXT NOT NULL,
stripe_subscription_id TEXT,
plan TEXT NOT NULL, -- 'free', 'pro', 'enterprise'
status TEXT NOT NULL, -- 'active', 'past_due', 'canceled'
current_period_end TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Synced via Stripe webhooks (see below).
B: Store as a Kratos trait
"traits": {
"email": "...",
"subscription": {
"plan": "pro",
"stripe_customer_id": "cus_..."
}
}Kratos traits are returned in the ID token. Your app reads id_token.subscription.plan for gating.
Trade-off: tying subscription to identity traits makes them mutable via Kratos's settings flow (which is wrong, users shouldn't edit their own subscription). Use approach A unless you want subscription claims in the ID token.
Wiring Stripe
Create Stripe customer on first paid signup
// When user clicks "Upgrade to Pro"
const session = await stripe.checkout.sessions.create({
customer_email: user.email,
client_reference_id: user.identity_id, // Critical: this is the Olympus identity_id
mode: "subscription",
line_items: [{ price: "price_xxx", quantity: 1 }],
success_url: "https://app.example.com/upgrade-success",
cancel_url: "https://app.example.com/pricing",
});
return Response.redirect(session.url!);Stripe webhook → your backend
// POST /api/stripe-webhook
const event = stripe.webhooks.constructEvent(...);
if (event.type === "customer.subscription.created" ||
event.type === "customer.subscription.updated") {
const sub = event.data.object;
const identityId = sub.metadata.identity_id; // or look up via customer_id
await db`INSERT INTO user_subscriptions (
identity_id, stripe_customer_id, stripe_subscription_id, plan, status, current_period_end
) VALUES (${identityId}, ${sub.customer}, ${sub.id}, ${sub.items.data[0].price.lookup_key}, ${sub.status}, ${new Date(sub.current_period_end * 1000)})
ON CONFLICT (identity_id) DO UPDATE SET ...`;
}Gate your feature endpoints
async function requirePlan(req: Request, minPlan: "pro" | "enterprise") {
const session = await getSession(req);
const sub = await db`SELECT * FROM user_subscriptions WHERE identity_id = ${session.identity_id}`.first();
if (!sub || sub.status !== "active") return Response.redirect("/pricing");
const planRank = { free: 0, pro: 1, enterprise: 2 };
if (planRank[sub.plan] < planRank[minPlan]) return Response.redirect("/pricing");
}Edge cases
- Past-due: subscription
status=past_due, allow limited access, prompt to update payment. - Cancellation mid-cycle: status is
canceledbutcurrent_period_endis in the future. Allow access until period end. - Webhook delay: Stripe webhook can lag by seconds. Build a "subscription pending" UI state for fresh checkouts.
Customer Portal
Let users manage their subscription via Stripe's hosted Customer Portal:
const portal = await stripe.billingPortal.sessions.create({
customer: sub.stripe_customer_id,
return_url: "https://app.example.com/settings",
});
return Response.redirect(portal.url);Account deletion
When deleting a user (see Cookbook, Self-service account deletion), also cancel their Stripe subscription:
await stripe.subscriptions.cancel(sub.stripe_subscription_id);