A business logic vulnerability is often one missing condition in otherwise valid code.
These flaws are specific to the context of the application. That context is what makes them difficult to reduce to a generic rule.
The handler authenticates the caller, validates the input, and makes a valid database query. A functional test covers the expected path. The code still violates a rule that matters to the product.
That rule may define which tenant owns a record, who can approve a refund, which subscription includes an export, or which state may follow another. Without that context, the implementation looks reasonable to a scanner, a coding agent, and a human reviewer.
The examples below use a fictional TypeScript SaaS application. Each one includes the rule that should hold, the vulnerable code, the exploit path, a security invariant, and the negative test that would expose the gap.
Coding agents run into the same problem as human reviewers. They can produce working code without knowing the private rules of the application. We measured that gap in Why AI Coding Agents Generate Business-Logic Vulnerabilities.
1. Tenant leakage through an overridable filter
Rule
A user may list invoices only from the tenant in their active session. Request filters may narrow that set, but never change the tenant.
Vulnerable code
const invoiceFilters = z.object({status: z.enum(['draft', 'open', 'paid']).optional(),tenantId: z.string().optional(),});async function listInvoices(session: Session, rawFilters: unknown) {if (!session) {throw new HttpError(401, 'Unauthorized');}const filters = invoiceFilters.parse(rawFilters);return db.invoice.findMany({where: {tenantId: session.tenantId,...filters,},});}
Why it looks valid
The query starts with tenantId: session.tenantId. The spread makes optional filters easy to add, and normal requests filter only by status.
Later keys in an object spread win. Because the filter schema also accepts tenantId, request input replaces the value from the session.
Exploit path
An authenticated user calls the list endpoint with tenantId=tenant-b. The parsed filter overwrites tenant-a from the session. The query returns invoices from the other tenant.
Invariant
Request input may narrow an invoice query, but it must never override the tenant from the active session.
Test
it('does not let request filters replace the active tenant', async () => {await createInvoice({ tenantId: 'tenant-a', status: 'open' });await createInvoice({ tenantId: 'tenant-b', status: 'open' });const session = sessionFor({ tenantId: 'tenant-a' });const invoices = await listInvoices(session, {status: 'open',tenantId: 'tenant-b',});expect(invoices).toHaveLength(1);expect(invoices[0].tenantId).toBe('tenant-a');});
2. Missing ownership check inside one tenant
Rule
Everyone on a tenant may read a draft report. Only its owner may publish it.
Vulnerable code
async function publishReport(session: Session, reportId: string) {const report = await db.report.findFirst({where: {id: reportId,tenantId: session.tenantId,},});if (!report) {throw new HttpError(404, 'Not found');}return db.report.update({where: { id: report.id },data: { status: 'published' },});}
Why it looks valid
This code fixes the cross-tenant problem. The query scopes the report to the caller's tenant before the update. A reviewer looking for tenant isolation can approve it.
The application has a second authorization boundary inside the tenant, and the route never checks report.ownerId. The access-control check is present, but at the wrong level.
Exploit path
A member copies the ID of a report owned by a colleague in the same tenant. They call the publish endpoint directly. The tenant check passes and the application publishes a report they do not control.
Invariant
Publishing a report requires the authenticated user to own that report, even when both users belong to the same tenant.
Test
it('rejects publication by another member of the tenant', async () => {const report = await createReport({tenantId: 'tenant-a',ownerId: 'user-a',status: 'draft',});const session = sessionFor({ tenantId: 'tenant-a', userId: 'user-b' });await expect(publishReport(session, report.id)).rejects.toMatchObject({status: 403,});expect(await getReportStatus(report.id)).toBe('draft');});
3. Role bypass on a protected action
Rule
Only billing administrators may change the payment method for an organization.
Vulnerable code
async function changePaymentMethod(session: Session,paymentMethodId: string,) {if (!session) {throw new HttpError(401, 'Unauthorized');}return billing.updateCustomer(session.tenantId, {paymentMethodId,});}
Why it looks valid
The operation uses the tenant ID from the trusted session instead of request input. It cannot update another tenant's billing account. The UI may also hide the control from ordinary members.
The server never enforces the role behind that UI decision.
Exploit path
An ordinary member finds the request in their browser or in the frontend code and replays it with another payment method ID. The organization's card changes without a billing administrator involved. The backend accepts it because any authenticated tenant member reaches the billing call.
Invariant
Changing an organization's payment method requires the
billing_adminrole in the active tenant.
Test
it('rejects payment-method changes from ordinary members', async () => {await setDefaultPaymentMethod('tenant-a', 'pm_original');const session = sessionFor({tenantId: 'tenant-a',role: 'member',});await expect(changePaymentMethod(session, 'pm_other'),).rejects.toMatchObject({ status: 403 });expect(await getDefaultPaymentMethod('tenant-a')).toBe('pm_original');});
4. Entitlement bypass through a direct API call
Rule
Bulk export is available only to tenants on the Enterprise plan.
Vulnerable code
async function startBulkExport(session: Session) {if (!session) {throw new HttpError(401, 'Unauthorized');}return exportQueue.add('bulk-export', {tenantId: session.tenantId,requestedBy: session.userId,});}
Why it looks valid
The route authenticates the user and takes the tenant from the session. The product UI checks the plan before showing the export button, so the expected browser path works correctly.
The API treats a commercial entitlement as a presentation detail. Direct requests do not pass through the UI check.
Exploit path
A user on the Starter plan discovers the export endpoint through frontend code, documentation, or a captured request from a previous trial. They call it directly. The backend queues the export without checking the tenant's current plan.
Invariant
A bulk export may start only when the active tenant has the Enterprise export entitlement.
Test
it('does not queue bulk exports for the Starter plan', async () => {const session = sessionFor({ tenantId: 'tenant-a' });await setPlan('tenant-a', 'starter');await expect(startBulkExport(session)).rejects.toMatchObject({status: 403,});expect(await countExportJobs('tenant-a')).toBe(0);});
5. Approval bypass that breaks separation of duties
Rule
The person who requests a refund may not approve it. Refunds above $10,000 require two independent finance approvers.
Vulnerable code
async function approveRefund(session: Session, refundId: string) {const refund = await db.refund.findFirst({where: {id: refundId,tenantId: session.tenantId,},include: { approvals: true },});if (!refund) {throw new HttpError(404, 'Not found');}if (session.role !== 'finance_approver') {throw new HttpError(403, 'Forbidden');}if (refund.approvals.some((item) => item.userId === session.userId)) {return refund;}await db.refundApproval.create({data: { refundId, userId: session.userId },});if (refund.approvals.length + 1 >= refund.requiredApprovals) {return db.refund.update({where: { id: refund.id },data: { status: 'approved' },});}return refund;}
Why it looks valid
The code scopes the refund to the tenant, prevents duplicate approvals, and counts the approvals before changing status.
It never checks whether the approver requested the refund. A correct counter can still count an invalid approval.
Exploit path
A finance approver files a high-value refund, approves it themselves, then asks a colleague for the second approval. The counter reaches two, but only one independent person reviewed the request.
Invariant
Refund approvals must exclude the requester, and refunds above $10,000 require two distinct finance approvers other than the requester.
Test
it('does not count the requester as a refund approver', async () => {const refund = await createRefund({tenantId: 'tenant-a',requestedBy: 'user-a',amountCents: 1_500_000,requiredApprovals: 2,});const requester = sessionFor({tenantId: 'tenant-a',userId: 'user-a',role: 'finance_approver',});await expect(approveRefund(requester, refund.id)).rejects.toMatchObject({status: 403,});expect(await countRefundApprovals(refund.id)).toBe(0);});
6. Invalid state transition through a valid action
Rule
A canceled subscription is terminal. Resume applies only to a past_due subscription; a canceled customer must start a new subscription.
Vulnerable code
async function resumeSubscription(session: Session, subscriptionId: string) {const subscription = await db.subscription.findFirst({where: {id: subscriptionId,tenantId: session.tenantId,},});if (!subscription) {throw new HttpError(404, 'Not found');}if (subscription.status === 'active') {return subscription;}return db.subscription.update({where: { id: subscription.id },data: { status: 'active', resumedAt: new Date() },});}
Why it looks valid
The lookup is tenant-scoped. The client sends no status value, so there is nothing to validate. Calling it twice on an active subscription is harmless.
The handler checks the destination state and never the current one. past_due is the intended source, but canceled and trialing take the same path.
Exploit path
A tenant whose subscription was canceled for non-payment calls the resume endpoint. The handler finds the subscription, sees it is not active, and sets it to active. Access comes back without a new checkout, contract, or billing mandate.
Invariant
A subscription status change must follow the approved transition graph.
canceledhas no outgoing transitions, and resume applies only topast_due.
Test
it('does not resume a canceled subscription', async () => {const subscription = await createSubscription({tenantId: 'tenant-a',status: 'canceled',});const session = sessionFor({ tenantId: 'tenant-a' });await expect(resumeSubscription(session, subscription.id),).rejects.toMatchObject({ status: 409 });expect(await getSubscriptionStatus(subscription.id)).toBe('canceled');});
7. Payment-rule bypass through client-controlled prices
Rule
The server calculates checkout totals from its price catalog and the tenant's eligible promotions. The client may choose a product and quantity, but not a price or discount.
Vulnerable code
const checkoutInput = z.object({productId: z.string(),quantity: z.number().int().min(1).max(100),unitPriceCents: z.number().int().nonnegative(),discountPercent: z.number().min(0).max(100),});async function createCheckout(session: Session, rawInput: unknown) {const input = checkoutInput.parse(rawInput);const subtotal = input.unitPriceCents * input.quantity;const totalCents = Math.round(subtotal * (1 - input.discountPercent / 100),);return paymentProvider.createCheckout({customerId: session.billingCustomerId,productId: input.productId,quantity: input.quantity,totalCents,});}
Why it looks valid
The input is typed and bounded. Negative prices, fractional quantities, and discounts over 100 percent are rejected.
Validation proves the values have an acceptable shape. It does not make the browser an authoritative source for price or promotion eligibility.
Exploit path
A customer changes unitPriceCents from 12000 to 1, or submits a 100 percent discount their account never received. Both values pass validation. The payment provider creates a checkout using the manipulated total.
Invariant
Checkout totals must come from the server-side price catalog and verified promotion eligibility, never client-supplied prices or discounts.
Test
it('ignores client-supplied prices and ineligible discounts', async () => {const session = sessionFor({ tenantId: 'tenant-a' });await setCatalogPrice('pro-seat', 12_000);const checkout = await createCheckout(session, {productId: 'pro-seat',quantity: 1,unitPriceCents: 1,discountPercent: 100,});expect(checkout.totalCents).toBe(12_000);});
Scanners and code review need the rule
A useful review states the invariant in a form that can fail: which tenant may read the record, which actor may change its state, and where the price comes from. The negative test follows from that rule.
Konvu Guardrails derives those invariants from the application's own code, docs, and threat models. It gives the coding agent the relevant one while it works and checks the change again in CI. It will not catch every business logic vulnerability, and it is in early access.
See how Konvu Guardrails checks application-specific security invariants before review or merge.