<?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: Auth By Example</title>
    <description>The latest articles on DEV Community by Auth By Example (@authbyexample1).</description>
    <link>https://dev.to/authbyexample1</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%2F4129486%2F81de18e5-31aa-4a61-8180-8828f484068b.png</url>
      <title>DEV Community: Auth By Example</title>
      <link>https://dev.to/authbyexample1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/authbyexample1"/>
    <language>en</language>
    <item>
      <title>A report that joins tables can leak rows your list pages would hide</title>
      <dc:creator>Auth By Example</dc:creator>
      <pubDate>Fri, 09 Oct 2026 05:41:05 +0000</pubDate>
      <link>https://dev.to/authbyexample1/a-report-that-joins-tables-can-leak-rows-your-list-pages-would-hide-52kk</link>
      <guid>https://dev.to/authbyexample1/a-report-that-joins-tables-can-leak-rows-your-list-pages-would-hide-52kk</guid>
      <description>&lt;p&gt;List endpoints usually filter by tenant and role. Reports often skip that. Someone builds a revenue report that joins &lt;code&gt;orders&lt;/code&gt; to &lt;code&gt;customers&lt;/code&gt; and &lt;code&gt;territories&lt;/code&gt;, then runs a raw SQL query or an ORM join with no per-table access filter.&lt;/p&gt;

&lt;p&gt;A sales rep who can only see their own territory still gets totals that include every order the join touches. Sometimes the detail rows are filtered and the summary cards are not, so the number on the dashboard is bigger than anything they can open.&lt;/p&gt;

&lt;p&gt;Apply the same access filter each list endpoint uses to every table in the join, before you aggregate. If the rep cannot read an order, it should not count toward their report either.&lt;/p&gt;

&lt;p&gt;Quick test: seed two territories, give a user access to only one, and run the report. The totals and the row count should match what their order list returns for the same filters.&lt;/p&gt;

</description>
      <category>authorization</category>
      <category>security</category>
      <category>backend</category>
      <category>saas</category>
    </item>
    <item>
      <title>What EU AI Act Article 12 and ISO 42001 mean for AI agent logs</title>
      <dc:creator>Auth By Example</dc:creator>
      <pubDate>Thu, 08 Oct 2026 08:23:42 +0000</pubDate>
      <link>https://dev.to/authbyexample1/what-eu-ai-act-article-12-and-iso-42001-mean-for-ai-agent-logs-29c</link>
      <guid>https://dev.to/authbyexample1/what-eu-ai-act-article-12-and-iso-42001-mean-for-ai-agent-logs-29c</guid>
      <description>&lt;p&gt;Article 12 of the EU AI Act says high-risk AI systems must technically allow automatic recording of events (logs) over the lifetime of the system. It covers high-risk systems only. An internal assistant that searches docs over MCP doesn't become high-risk because it uses MCP. Per artificialintelligenceact.eu, the obligations apply from 2 December 2027 for Annex III systems and 2 August 2028 for Annex I systems. Check the current consolidated text on EUR-Lex for your own case; none of this is legal advice.&lt;/p&gt;

&lt;p&gt;ISO/IEC 42001 is broader and vaguer. Control A.6.2.8 asks you to decide in which life-cycle phases event logging is enabled, and "while the AI system is in use" is the minimum.&lt;/p&gt;

&lt;p&gt;For agents I'd put that logging at the tool call. The model only suggests things, and the tool call is where data actually changes. Before each call with side effects, record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;who or what asked, and on whose behalf the agent acted&lt;/li&gt;
&lt;li&gt;the tool, the action, and the resource it touches&lt;/li&gt;
&lt;li&gt;the decision (allow, deny, or allow with approval) and the policy version behind it&lt;/li&gt;
&lt;li&gt;who approved it, if a human had to sign off&lt;/li&gt;
&lt;li&gt;what happened after the decision&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Join those with one request ID. Log redacted parameters or hashes instead of raw prompts, so you can replay the decision later without hoarding data.&lt;/p&gt;

&lt;p&gt;Disclosure: I work at Permit.io. We wrote up the Article 12 and ISO 42001 details, with a full field table for MCP tool calls:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.permit.io/blog/eu-ai-act-article-12-iso-42001-agent-logging?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=eu-ai-act-article-12-iso-42001-agent-logging&amp;amp;utm_content=authbyexample-article" rel="noopener noreferrer"&gt;https://www.permit.io/blog/eu-ai-act-article-12-iso-42001-agent-logging?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=eu-ai-act-article-12-iso-42001-agent-logging&amp;amp;utm_content=authbyexample-article&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>compliance</category>
      <category>authorization</category>
    </item>
    <item>
      <title>GraphQL resolvers often check the top-level object and skip the nested ones</title>
      <dc:creator>Auth By Example</dc:creator>
      <pubDate>Thu, 08 Oct 2026 06:09:13 +0000</pubDate>
      <link>https://dev.to/authbyexample1/graphql-resolvers-often-check-the-top-level-object-and-skip-the-nested-ones-51ki</link>
      <guid>https://dev.to/authbyexample1/graphql-resolvers-often-check-the-top-level-object-and-skip-the-nested-ones-51ki</guid>
      <description>&lt;p&gt;A common GraphQL setup puts the permission check in the root resolver. &lt;code&gt;project(id: 7)&lt;/code&gt; checks that you're a member of project 7 and returns it. Every nested field resolves on its own after that.&lt;/p&gt;

&lt;p&gt;So a query like &lt;code&gt;project(id: 7) { linkedProjects { name members { email } } }&lt;/code&gt; can walk right out of the project you were allowed to see. A linked project might belong to another team, and its &lt;code&gt;members&lt;/code&gt; resolver never asked whether you can see that team.&lt;/p&gt;

&lt;p&gt;What fixes most of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Put the check in each type's resolver, or in the data loader that fetches that type, keyed on the object being returned.&lt;/li&gt;
&lt;li&gt;Treat any field that returns another object as a new authorization question, even when the parent passed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To test it, sign in as a user who belongs to one project and write a query that follows every relation two or three levels deep. Any object from outside their projects that comes back is a hole. Introspection gives you the full list of relations to try.&lt;/p&gt;

</description>
      <category>graphql</category>
      <category>authorization</category>
      <category>security</category>
      <category>api</category>
    </item>
    <item>
      <title>The user picker in your share dialog can leak your whole user table</title>
      <dc:creator>Auth By Example</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:18:57 +0000</pubDate>
      <link>https://dev.to/authbyexample1/the-user-picker-in-your-share-dialog-can-leak-your-whole-user-table-2kgj</link>
      <guid>https://dev.to/authbyexample1/the-user-picker-in-your-share-dialog-can-leak-your-whole-user-table-2kgj</guid>
      <description>&lt;p&gt;Most apps have a little search box for picking people when you share a doc or assign a ticket. Behind it there's an endpoint like &lt;code&gt;GET /users?q=ali&lt;/code&gt; that returns names and emails as you type.&lt;/p&gt;

&lt;p&gt;That endpoint tends to get written quickly and reviewed last. Often the only check is whether the caller is logged in. So any signed-in user can type one letter at a time and walk your whole user table, across every tenant, emails included.&lt;/p&gt;

&lt;p&gt;A few checks that close it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scope the query to the people the caller is allowed to see. In a multi-tenant app that means the caller's organization, and sometimes only members of the same workspace or project.&lt;/li&gt;
&lt;li&gt;Check the action too. If the picker is for sharing document 42, confirm the caller can share document 42 before returning anyone.&lt;/li&gt;
&lt;li&gt;Return only what the picker needs. A display name and an avatar are usually enough. Leave email out unless the caller already has a reason to see it.&lt;/li&gt;
&lt;li&gt;Set a minimum query length and a rate limit, so nobody can page through the list with &lt;code&gt;q=a&lt;/code&gt;, &lt;code&gt;q=b&lt;/code&gt;, &lt;code&gt;q=c&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Quick test: log in as a user in tenant A and search for a name you know exists only in tenant B. If it shows up, the picker is leaking.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>api</category>
      <category>backend</category>
    </item>
    <item>
      <title>An admin shouldn't be able to grant a role bigger than their own</title>
      <dc:creator>Auth By Example</dc:creator>
      <pubDate>Wed, 07 Oct 2026 12:11:06 +0000</pubDate>
      <link>https://dev.to/authbyexample1/an-admin-shouldnt-be-able-to-grant-a-role-bigger-than-their-own-5en5</link>
      <guid>https://dev.to/authbyexample1/an-admin-shouldnt-be-able-to-grant-a-role-bigger-than-their-own-5en5</guid>
      <description>&lt;p&gt;Most role-assignment endpoints check one thing: can the caller manage members? If yes, whatever role is in the request body goes through.&lt;/p&gt;

&lt;p&gt;So a project admin sends &lt;code&gt;PATCH /members/42 {"role": "owner"}&lt;/code&gt; with their own user ID, or a friend's, and now they hold permissions nobody gave them. The UI dropdown hid "owner". The API never did.&lt;/p&gt;

&lt;p&gt;Two checks cover most of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The caller can only assign roles whose permissions are a subset of their own on that same resource.&lt;/li&gt;
&lt;li&gt;The caller can't raise their own role. Someone with a higher role has to do that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To test it, sign in with the lowest role that can manage members and call the endpoint directly with every role name your system has, including the ones that never show up in the dropdown. Anything above the caller's own level should come back 403.&lt;/p&gt;

&lt;p&gt;If you want a refresher on how roles and permissions fit together, Permit.io has a short write-up (we're affiliated): &lt;a href="https://www.permit.io/blog/what-is-rbac?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=what-is-rbac&amp;amp;utm_content=authbyexample-article" rel="noopener noreferrer"&gt;What is RBAC?&lt;/a&gt;&lt;/p&gt;

</description>
      <category>authorization</category>
      <category>security</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Archiving a workspace should make the API read-only too</title>
      <dc:creator>Auth By Example</dc:creator>
      <pubDate>Wed, 07 Oct 2026 10:39:44 +0000</pubDate>
      <link>https://dev.to/authbyexample1/archiving-a-workspace-should-make-the-api-read-only-too-45m</link>
      <guid>https://dev.to/authbyexample1/archiving-a-workspace-should-make-the-api-read-only-too-45m</guid>
      <description>&lt;p&gt;Archiving usually ships as a UI change. The workspace moves to an "Archived" tab, the edit buttons go grey, and everyone moves on.&lt;/p&gt;

&lt;p&gt;The API often never hears about it. A script with an old token, a webhook handler, or a teammate with a bookmarked form can still create tasks, rename files, or invite people into a workspace that's supposed to be frozen. Billing tends to break the same way: a customer downgrades or stops paying, the screens lock, and writes keep landing.&lt;/p&gt;

&lt;p&gt;What helps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Put the workspace state into the permission check itself. "Can this user edit tasks here?" should come back no when the workspace is archived, whatever role they hold.&lt;/li&gt;
&lt;li&gt;Keep reads working. People archive things so they can still look them up later.&lt;/li&gt;
&lt;li&gt;Decide what unarchiving needs. Usually that's an admin or owner action, and it should be checked and logged like any other role change.&lt;/li&gt;
&lt;li&gt;Cover background jobs. Imports, syncs, and scheduled automations that write into the workspace should check the same state before they run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Quick test: archive a workspace, then send a create or update request straight to the API with a token that was valid before. You want a 403 with a clear reason. A 200 means the archive only exists in the UI.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
    <item>
      <title>Four questions to ask about one real authorization flow</title>
      <dc:creator>Auth By Example</dc:creator>
      <pubDate>Tue, 06 Oct 2026 13:31:32 +0000</pubDate>
      <link>https://dev.to/authbyexample1/four-questions-to-ask-about-one-real-authorization-flow-3j9g</link>
      <guid>https://dev.to/authbyexample1/four-questions-to-ask-about-one-real-authorization-flow-3j9g</guid>
      <description>&lt;p&gt;A lot of authorization trouble has nothing to do with missing roles. The check exists somewhere, just not at the action that matters, or it runs on data that went stale an hour ago.&lt;/p&gt;

&lt;p&gt;Try this on one flow that can actually hurt you, like inviting a user, exporting a report, or an agent calling a tool.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the action itself ask for a decision? A role check at login or on the route won't cover the list query that forgets to filter by tenant.&lt;/li&gt;
&lt;li&gt;Does the decision know enough? &lt;code&gt;user.role == "admin"&lt;/code&gt; ignores tenant scope, ownership and resource state. You want something closer to "can subject U do action A on resource R in tenant T right now?"&lt;/li&gt;
&lt;li&gt;How long does a revoke take? If a removed user keeps export access until a 30-minute cache expires, decide whether that's fine for that action. For a UI hint, maybe. For a data export, probably not.&lt;/li&gt;
&lt;li&gt;Can you explain the result later? A log line that says 403 won't tell anyone which policy decided or why.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Disclosure: I work at Permit.io. We wrote up these four areas (enforcement, granularity, realtime, audit) and the architecture that connects them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.permit.io/blog/enforcement-granularity-realtime-audit?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=enforcement-granularity-realtime-audit&amp;amp;utm_content=authbyexample-article" rel="noopener noreferrer"&gt;https://www.permit.io/blog/enforcement-granularity-realtime-audit?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=enforcement-granularity-realtime-audit&amp;amp;utm_content=authbyexample-article&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>saas</category>
      <category>security</category>
      <category>authorization</category>
    </item>
    <item>
      <title>Duplicating a record should check access to everything it copies</title>
      <dc:creator>Auth By Example</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:36:57 +0000</pubDate>
      <link>https://dev.to/authbyexample1/duplicating-a-record-should-check-access-to-everything-it-copies-fia</link>
      <guid>https://dev.to/authbyexample1/duplicating-a-record-should-check-access-to-everything-it-copies-fia</guid>
      <description>&lt;p&gt;"Duplicate project" is a handy button. The handler usually checks that the caller can read the project, then copies the project row and everything hanging off it: tasks, attachments, linked documents, saved filters.&lt;/p&gt;

&lt;p&gt;The children don't always share the parent's permissions, though. A private task inside a shared project, or a document linked in from another workspace, gets copied into a new project the caller owns. Now they can read it.&lt;/p&gt;

&lt;p&gt;What I'd do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run the read check on each child you copy, with the caller as the user. Leave out the ones that fail and tell the caller some items were skipped.&lt;/li&gt;
&lt;li&gt;Copy references to other workspaces as links, so opening them still goes through the normal check.&lt;/li&gt;
&lt;li&gt;Templates and "save as template" usually share this code path. Check them too.&lt;/li&gt;
&lt;li&gt;Add a test: user A duplicates a project that contains a task only user B can see. The copy should not contain that task.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want every route to call one shared decision function, this write-up on &lt;a href="https://www.permit.io/blog/pep-pdp-architecture?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=pep-pdp-architecture&amp;amp;utm_content=authbyexample-article" rel="noopener noreferrer"&gt;splitting enforcement from the decision (PEP/PDP)&lt;/a&gt; covers the pattern. Disclosure: I work with Permit.io.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
    <item>
      <title>Attachment downloads should check the record they belong to</title>
      <dc:creator>Auth By Example</dc:creator>
      <pubDate>Tue, 06 Oct 2026 06:44:40 +0000</pubDate>
      <link>https://dev.to/authbyexample1/attachment-downloads-should-check-the-record-they-belong-to-3fb0</link>
      <guid>https://dev.to/authbyexample1/attachment-downloads-should-check-the-record-they-belong-to-3fb0</guid>
      <description>&lt;p&gt;Your &lt;code&gt;GET /tickets/:id&lt;/code&gt; handler checks that the caller can see the ticket. The files attached to that ticket come from &lt;code&gt;GET /files/:fileId&lt;/code&gt;, and that route only checks that someone is logged in.&lt;/p&gt;

&lt;p&gt;So anyone holding a file ID can download a screenshot from another customer's support ticket. File IDs end up in notification emails and server logs, and they keep working after a user loses access to the ticket.&lt;/p&gt;

&lt;p&gt;What I'd do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store the parent on the file row (&lt;code&gt;parent_type&lt;/code&gt;, &lt;code&gt;parent_id&lt;/code&gt;). The download route loads the parent and runs the same check the parent's own endpoint runs.&lt;/li&gt;
&lt;li&gt;If you hand out signed storage URLs, sign them only after that check passes and keep the expiry short, a few minutes is plenty.&lt;/li&gt;
&lt;li&gt;Thumbnails, previews and "download all as zip" usually have their own routes. Give them the same check, they are the ones that get missed.&lt;/li&gt;
&lt;li&gt;Add a test where user A uploads to a ticket and user B from another tenant requests the file ID directly. B should get a 404.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
    <item>
      <title>Counts and totals need the same authorization filter as the list</title>
      <dc:creator>Auth By Example</dc:creator>
      <pubDate>Tue, 06 Oct 2026 05:43:22 +0000</pubDate>
      <link>https://dev.to/authbyexample1/counts-and-totals-need-the-same-authorization-filter-as-the-list-4d3m</link>
      <guid>https://dev.to/authbyexample1/counts-and-totals-need-the-same-authorization-filter-as-the-list-4d3m</guid>
      <description>&lt;p&gt;You locked down &lt;code&gt;GET /invoices&lt;/code&gt; so it only returns rows the caller can see. Then the dashboard says "Invoices this month: 4,812", and that number comes from &lt;code&gt;SELECT count(*) FROM invoices&lt;/code&gt; with no tenant filter.&lt;/p&gt;

&lt;p&gt;Counts leak. So do sums, search facets like "Overdue (37)", and the "20 of 3,104" line under a paginated table. When those run on their own query path they can tell a user how large another tenant's account is, or whether a given customer exists at all, without returning a single row.&lt;/p&gt;

&lt;p&gt;What I'd do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build the authorization filter in one place and pass it to the list query, the count query and every aggregate.&lt;/li&gt;
&lt;li&gt;Check search facets and pagination totals separately. They often come from a different index call than the hits.&lt;/li&gt;
&lt;li&gt;Add a test: a user who can see 3 invoices gets 3 from the count endpoint, and the facet numbers add up to 3.&lt;/li&gt;
&lt;li&gt;If dashboard numbers are cached, put the tenant (or the user, when access is per user) in the cache key.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
    <item>
      <title>A valid token is not an AI agent audit trail</title>
      <dc:creator>Auth By Example</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:15:34 +0000</pubDate>
      <link>https://dev.to/authbyexample1/a-valid-token-is-not-an-ai-agent-audit-trail-6fh</link>
      <guid>https://dev.to/authbyexample1/a-valid-token-is-not-an-ai-agent-audit-trail-6fh</guid>
      <description>&lt;p&gt;When an AI agent issues a refund or edits a record, your IdP log says a token existed and your app log says an endpoint was hit. Neither tells a reviewer &lt;em&gt;why the action was allowed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The record that does is written at decision time, and it needs a few things most stacks don't capture today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delegation&lt;/strong&gt;: which agent acted, and on behalf of which human or workflow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool and parameters&lt;/strong&gt;: what was actually requested, not just which API was called&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy ID and version&lt;/strong&gt;: the rule set that was evaluated at that moment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decision plus reason&lt;/strong&gt;: allow or deny, with something a person can read&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approval record&lt;/strong&gt;: who approved a human-in-the-loop step, and when&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outcome&lt;/strong&gt;: whether the side effect really happened&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Join them with a trace ID and a tenant, and you can rebuild an agent's action months later without guessing.&lt;/p&gt;

&lt;p&gt;Disclosure: I work at Permit.io. We wrote up the full twelve-field list, with an example record and what to leave out for read-only assistants:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.permit.io/blog/ai-agent-audit-logs-12-fields?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=ai-agent-audit-logs-12-fields&amp;amp;utm_content=authbyexample-article" rel="noopener noreferrer"&gt;https://www.permit.io/blog/ai-agent-audit-logs-12-fields?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=ai-agent-audit-logs-12-fields&amp;amp;utm_content=authbyexample-article&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>authorization</category>
      <category>compliance</category>
    </item>
    <item>
      <title>An email domain is not tenant membership</title>
      <dc:creator>Auth By Example</dc:creator>
      <pubDate>Mon, 05 Oct 2026 07:43:12 +0000</pubDate>
      <link>https://dev.to/authbyexample1/an-email-domain-is-not-tenant-membership-57oi</link>
      <guid>https://dev.to/authbyexample1/an-email-domain-is-not-tenant-membership-57oi</guid>
      <description>&lt;p&gt;A common shortcut in B2B apps: "if the user's email ends in &lt;code&gt;@acme.com&lt;/code&gt;, put them in the Acme workspace." It feels like authorization, because only Acme employees &lt;em&gt;should&lt;/em&gt; have Acme addresses. But the domain only tells you who issued the mailbox, not who your customer has agreed to let in.&lt;/p&gt;

&lt;p&gt;Things that break this assumption:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;contractors, interns, and shared inboxes that use the company domain but shouldn't see everything&lt;/li&gt;
&lt;li&gt;a former employee whose mailbox is still alive for a few weeks&lt;/li&gt;
&lt;li&gt;subsidiaries or acquired companies that share a domain but are separate customers&lt;/li&gt;
&lt;li&gt;public email providers, where "same domain" means nothing at all&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A small example
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Risky: the domain decides membership
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;org&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orgs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by_domain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;memberships&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;member&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Safer: the domain only suggests, the org decides
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;org&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orgs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by_verified_domain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auto_join_enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;memberships&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default_join_role&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;join_requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# an org admin approves
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second version still uses the domain, but only as a hint. Membership comes from a decision the customer made: a verified domain, an explicit auto-join setting, and a default role they chose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What helps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify domain ownership first&lt;/strong&gt; (DNS TXT record or similar) before treating a domain as belonging to an org.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make auto-join opt-in&lt;/strong&gt; per org, and let admins pick the default role. "Member" in one company is "read-only guest" in another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep checking membership, not the email.&lt;/strong&gt; Every request should ask "is this user a member of this tenant with this role?", never "does their email match?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle offboarding separately.&lt;/strong&gt; SCIM or an admin removal should revoke membership even if the mailbox still exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never auto-join on public domains&lt;/strong&gt; like gmail.com or outlook.com.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The email domain is a useful signal for &lt;em&gt;suggesting&lt;/em&gt; a workspace. It shouldn't be the thing that grants access to one. Let the tenant decide who's in, store that decision, and authorize against it.&lt;/p&gt;

&lt;p&gt;How does your app handle domain-based auto-join today?&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>saas</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
