<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: wendel andrady</title>
    <description>The latest articles on DEV Community by wendel andrady (@wendel_andrady).</description>
    <link>https://dev.to/wendel_andrady</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4112475%2F24dcd45e-07f9-4aa7-a8d0-2698df11e4fa.png</url>
      <title>DEV Community: wendel andrady</title>
      <link>https://dev.to/wendel_andrady</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wendel_andrady"/>
    <language>en</language>
    <item>
      <title>RLS was enabled and tenants could still read each other's data — the SECURITY DEFINER hole, and the query that catches it</title>
      <dc:creator>wendel andrady</dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:50:31 +0000</pubDate>
      <link>https://dev.to/wendel_andrady/rls-was-enabled-and-tenants-could-still-read-each-others-data-the-security-definer-hole-and-the-3fh3</link>
      <guid>https://dev.to/wendel_andrady/rls-was-enabled-and-tenants-could-still-read-each-others-data-the-security-definer-hole-and-the-3fh3</guid>
      <description>&lt;p&gt;RLS protects rows from the invoking user's context. It does nothing about a function that runs SECURITY DEFINER — that function executes with the owner's rights, and unless it re-implements tenant filtering internally, every caller rides past your policies. This isn't theoretical: I found exactly this in my own multi-tenant app. RLS was on, policies looked right, and a single RPC that returns usage data still crossed tenants — because it filtered by an org_id the caller passed in as an argument.&lt;/p&gt;

&lt;p&gt;The mechanics, in one paragraph. Postgres evaluates RLS against the role executing the query. SECURITY DEFINER swaps that role for the function owner — typically the table owner, which your policies exempt or treat as privileged. If the function does SELECT * FROM usage WHERE org_id = $1 without pinning the tenant to the caller's org, the caller chooses $1. RLS never gets a vote.&lt;/p&gt;

&lt;p&gt;How I caught it. Not by reading code — by writing an adversarial test that logs in as org A, calls the RPC with org B's id, and asserts zero rows come back. Rows came back. That test ships in the project's hardening suite now, and the fix went out as a numbered migration.&lt;/p&gt;

&lt;p&gt;Three fixes to consider, in order of preference: (1) make the definer honor RLS anyway — explicit org_id filter derived from caller identity (in Supabase, auth.uid() from the JWT; in plain Postgres, current_user or a session GUC), never from arguments; (2) if the function must be definer for a reason, SET search_path explicitly and REVOKE from roles that shouldn't call it; (3) add a regression test that tries the cross-tenant call — if you only test the happy path, this class of bug ships.&lt;/p&gt;

&lt;p&gt;Audit query to run today — list every SECURITY DEFINER function in your database, then check each one filters by caller identity rather than arguments:&lt;/p&gt;

&lt;p&gt;sql&lt;/p&gt;

&lt;p&gt;select n.nspname, p.proname&lt;br&gt;
from pg_proc p join pg_namespace n on n.oid = p.pronamespace&lt;br&gt;
where p.prosecdef = true;&lt;br&gt;
Full write-up with the migration that fixed it is on my site's postmortems page: &lt;a href="https://andrady.co/postmortems/security-definer" rel="noopener noreferrer"&gt;https://andrady.co/postmortems/security-definer&lt;/a&gt;. Disclosure: I also sell a multi-tenant Next.js + Supabase starter (B2B SaaS OS) that ships this test suite — but everything above is useful whether or not you ever look at it.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>postgres</category>
      <category>security</category>
    </item>
    <item>
      <title>Your B2B SaaS Isn't Production-Ready Until You Test These 10 Failure Cases</title>
      <dc:creator>wendel andrady</dc:creator>
      <pubDate>Wed, 09 Sep 2026 21:55:52 +0000</pubDate>
      <link>https://dev.to/wendel_andrady/your-b2b-saas-isnt-production-ready-until-you-test-these-10-failure-cases-2282</link>
      <guid>https://dev.to/wendel_andrady/your-b2b-saas-isnt-production-ready-until-you-test-these-10-failure-cases-2282</guid>
      <description>&lt;p&gt;Most B2B SaaS security testing focuses on the happy path.&lt;/p&gt;

&lt;p&gt;A user signs in.&lt;/p&gt;

&lt;p&gt;They can access their organization's data.&lt;/p&gt;

&lt;p&gt;The right role can perform the right action.&lt;/p&gt;

&lt;p&gt;Stripe sends a webhook and the subscription updates.&lt;/p&gt;

&lt;p&gt;Everything looks correct.&lt;/p&gt;

&lt;p&gt;But production failures usually happen somewhere else:&lt;/p&gt;

&lt;p&gt;When someone does something they're not supposed to be able to do.&lt;/p&gt;

&lt;p&gt;Here are 10 failure cases I think every multi-tenant B2B SaaS should test before production.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User A can access User B's data&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Create two organizations:&lt;/p&gt;

&lt;p&gt;Organization A&lt;br&gt;
└── User A&lt;br&gt;
    └── Project A&lt;/p&gt;

&lt;p&gt;Organization B&lt;br&gt;
└── User B&lt;br&gt;
    └── Project B&lt;/p&gt;

&lt;p&gt;Then try to access Project B while authenticated as User A.&lt;/p&gt;

&lt;p&gt;Don't only test through the UI.&lt;/p&gt;

&lt;p&gt;Try:&lt;/p&gt;

&lt;p&gt;Direct API requests&lt;br&gt;
Known resource IDs&lt;br&gt;
Modified URLs&lt;br&gt;
Nested resources&lt;br&gt;
Search endpoints&lt;/p&gt;

&lt;p&gt;The expected result should always be denial.&lt;/p&gt;

&lt;p&gt;A tenant_id column by itself doesn't provide tenant isolation.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Changing a resource ID bypasses authorization&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Suppose your endpoint is:&lt;/p&gt;

&lt;p&gt;GET /api/projects/123&lt;/p&gt;

&lt;p&gt;What happens if User A changes 123 to 456, where 456 belongs to another organization?&lt;/p&gt;

&lt;p&gt;This is a classic place for authorization bugs.&lt;/p&gt;

&lt;p&gt;The server needs to verify both:&lt;/p&gt;

&lt;p&gt;The resource exists.&lt;br&gt;
The current user is allowed to access it.&lt;/p&gt;

&lt;p&gt;Never treat possession of a resource ID as authorization.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;RBAC works in the UI but not the API&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Hiding an admin button from a member isn't security.&lt;/p&gt;

&lt;p&gt;Try calling the underlying endpoint directly as a member.&lt;/p&gt;

&lt;p&gt;For every sensitive action, test:&lt;/p&gt;

&lt;p&gt;Owner&lt;br&gt;
Admin&lt;br&gt;
Member&lt;br&gt;
Unauthenticated user&lt;/p&gt;

&lt;p&gt;Authorization needs to exist at the server boundary, not just in the frontend.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A revoked invitation can still be used&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Invitations have more states than:&lt;/p&gt;

&lt;p&gt;invited → accepted&lt;/p&gt;

&lt;p&gt;You should test:&lt;/p&gt;

&lt;p&gt;Expired invitation&lt;br&gt;
Revoked invitation&lt;br&gt;
Already-used invitation&lt;br&gt;
Invitation belonging to another organization&lt;/p&gt;

&lt;p&gt;An invitation should not become a permanent access token.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A revoked API key still works&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;API keys are another authentication path.&lt;/p&gt;

&lt;p&gt;Test:&lt;/p&gt;

&lt;p&gt;Valid key       → allowed&lt;br&gt;
Invalid key     → denied&lt;br&gt;
Revoked key     → denied&lt;br&gt;
Expired key     → denied&lt;br&gt;
Wrong tenant    → denied&lt;/p&gt;

&lt;p&gt;Also verify that API-key permissions are enforced just like browser-based authentication.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Usage limits can be bypassed&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your SaaS has usage limits, don't only test:&lt;/p&gt;

&lt;p&gt;User reaches limit → request fails.&lt;/p&gt;

&lt;p&gt;Try concurrent requests.&lt;/p&gt;

&lt;p&gt;For example, if the limit is 100 operations, what happens when 20 requests arrive simultaneously while the tenant has 95 operations remaining?&lt;/p&gt;

&lt;p&gt;If the application checks and increments usage incorrectly, several requests may pass before the limit is updated.&lt;/p&gt;

&lt;p&gt;Usage enforcement needs to happen server-side and should account for concurrency where relevant.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The same Stripe webhook produces the effect twice&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Webhook providers retry events.&lt;/p&gt;

&lt;p&gt;Your application should assume that the same event can arrive more than once.&lt;/p&gt;

&lt;p&gt;Test:&lt;/p&gt;

&lt;p&gt;Webhook received&lt;br&gt;
Webhook received again&lt;/p&gt;

&lt;p&gt;The second delivery should not:&lt;/p&gt;

&lt;p&gt;Create a duplicate subscription&lt;br&gt;
Apply a credit twice&lt;br&gt;
Process an upgrade twice&lt;br&gt;
Trigger duplicate provisioning&lt;/p&gt;

&lt;p&gt;Webhook processing should be idempotent.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A background job loses tenant context&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your HTTP request may correctly know:&lt;/p&gt;

&lt;p&gt;organization_id = A&lt;/p&gt;

&lt;p&gt;But what happens when that work moves into a background job?&lt;/p&gt;

&lt;p&gt;The job needs enough trusted context to know which tenant it belongs to.&lt;/p&gt;

&lt;p&gt;Test jobs that:&lt;/p&gt;

&lt;p&gt;Read tenant data&lt;br&gt;
Modify tenant data&lt;br&gt;
Delete tenant data&lt;br&gt;
Process billing events&lt;br&gt;
Process usage&lt;/p&gt;

&lt;p&gt;Background jobs are still part of your application's security boundary.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A new table doesn't have tenant isolation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is one of the easiest problems to introduce as a SaaS grows.&lt;/p&gt;

&lt;p&gt;You add:&lt;/p&gt;

&lt;p&gt;reports&lt;/p&gt;

&lt;p&gt;The application works.&lt;/p&gt;

&lt;p&gt;Existing tests pass.&lt;/p&gt;

&lt;p&gt;But the new table doesn't have the same isolation protections as your other tenant-owned resources.&lt;/p&gt;

&lt;p&gt;For every tenant-owned table, verify:&lt;/p&gt;

&lt;p&gt;[ ] Ownership relationship exists&lt;br&gt;
[ ] Database policy exists&lt;br&gt;
[ ] Server authorization exists&lt;br&gt;
[ ] Cross-tenant read tested&lt;br&gt;
[ ] Cross-tenant write tested&lt;br&gt;
[ ] Cross-tenant delete tested&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Related resources create an indirect access path&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Imagine:&lt;/p&gt;

&lt;p&gt;Organization&lt;br&gt;
└── Project&lt;br&gt;
    └── Document&lt;/p&gt;

&lt;p&gt;You may correctly protect Projects but forget that someone can access a Document directly.&lt;/p&gt;

&lt;p&gt;Test the entire chain.&lt;/p&gt;

&lt;p&gt;Can User A access a Document belonging to Organization B by knowing:&lt;/p&gt;

&lt;p&gt;The document ID?&lt;br&gt;
The project ID?&lt;br&gt;
A nested endpoint?&lt;br&gt;
A search query?&lt;br&gt;
A download URL?&lt;/p&gt;

&lt;p&gt;Tenant isolation needs to survive indirect access paths too.&lt;/p&gt;

&lt;p&gt;The test that matters most&lt;/p&gt;

&lt;p&gt;Don't only ask:&lt;/p&gt;

&lt;p&gt;"Does my SaaS work?"&lt;/p&gt;

&lt;p&gt;Ask:&lt;/p&gt;

&lt;p&gt;"Can I make my SaaS do something I'm not authorized to do?"&lt;/p&gt;

&lt;p&gt;That's the test that exposes the gaps.&lt;/p&gt;

&lt;p&gt;Free B2B SaaS production checklist&lt;/p&gt;

&lt;p&gt;I put the complete production-readiness checklist and a deeper tenant-isolation guide into a free GitHub repository.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/wendelandrady/production-saas-readiness" rel="noopener noreferrer"&gt;production-saas-readiness&lt;/a&gt;&lt;br&gt;
It covers authentication, multi-tenancy, RLS, RBAC, invitations, API keys, usage limits, billing, webhooks, background jobs, and production verification.&lt;/p&gt;

&lt;p&gt;If you'd rather start with this infrastructure already implemented instead of building it yourself, I've also built a B2B SaaS OS around these components.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://andrady.co" rel="noopener noreferrer"&gt;andrady.co&lt;/a&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>security</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>How I approached multi-tenancy in Next.js with PostgreSQL RLS</title>
      <dc:creator>wendel andrady</dc:creator>
      <pubDate>Sun, 06 Sep 2026 15:19:35 +0000</pubDate>
      <link>https://dev.to/wendel_andrady/how-i-approached-multi-tenancy-in-nextjs-with-postgresql-rls-1d8e</link>
      <guid>https://dev.to/wendel_andrady/how-i-approached-multi-tenancy-in-nextjs-with-postgresql-rls-1d8e</guid>
      <description>&lt;p&gt;Multi-tenancy looks simple at first.&lt;/p&gt;

&lt;p&gt;You add an &lt;code&gt;organization_id&lt;/code&gt; to your tables, check it in your queries, and move on.&lt;/p&gt;

&lt;p&gt;The problem is that this puts a lot of responsibility on application code.&lt;/p&gt;

&lt;p&gt;I wanted tenant isolation to be enforced at the database level too, so I used PostgreSQL Row Level Security (RLS).&lt;/p&gt;

&lt;p&gt;The basic architecture is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users belong to organizations&lt;/li&gt;
&lt;li&gt;Organization membership determines access&lt;/li&gt;
&lt;li&gt;Server-side authorization handles application permissions&lt;/li&gt;
&lt;li&gt;PostgreSQL RLS provides another layer of tenant isolation&lt;/li&gt;
&lt;li&gt;Roles such as Owner, Admin, and Member control what users can do&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This also makes other parts of the application more interesting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Team invitations&lt;/li&gt;
&lt;li&gt;Organization switching&lt;/li&gt;
&lt;li&gt;API keys&lt;/li&gt;
&lt;li&gt;Usage limits&lt;/li&gt;
&lt;li&gt;Billing tied to organizations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest lesson for me was that multi-tenancy isn't really a single feature.&lt;/p&gt;

&lt;p&gt;It's an authorization model that needs to stay consistent across the application and database.&lt;/p&gt;

&lt;p&gt;I built these patterns into a reusable B2B SaaS foundation because I got tired of rebuilding them for every project.&lt;/p&gt;

&lt;p&gt;If you're building a multi-tenant SaaS with Next.js and Supabase, I've put the full foundation here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://andrady.co" rel="noopener noreferrer"&gt;https://andrady.co&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd be interested to hear how other Next.js developers approach tenant isolation. Do you rely mainly on application-level checks, RLS, or both?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>nextjs</category>
      <category>security</category>
    </item>
  </channel>
</rss>
