Skip to content

Organizations

An Organization is a shared tenant within your environment — a named entity that groups users via memberships, carries its own roles and verified domains, and injects an org claim into session JWTs when a member makes it their active organization.

Use organizations when your product is multi-tenant and users belong to accounts they didn’t personally create (teams, companies, projects, etc.). Single-user products don’t need them.

Terminal window
curl -X POST https://<FAPI_URL>/v1/organizations \
-H "Authorization: Bearer <secret_key>" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Inc.",
"slug": "acme",
"created_by": "user_01HKX9SY9V7H7TF8C8K7J9X4ZB"
}'
use AuthnSh\Sdk\Authn;
$org = Authn::organizations()->create([
'name' => 'Acme Inc.',
'slug' => 'acme',
'created_by' => $user->authn_id,
]);

The created_by user is automatically added as a member with the org:admin role (is_creator_eligible: true).

import { useOrganizationActions } from '@authn.sh/sdk-react';
function CreateOrgButton() {
const { createOrganization } = useOrganizationActions();
return (
<button
onClick={() =>
createOrganization({ name: 'Acme Inc.', slug: 'acme' })
}
>
Create organization
</button>
);
}

Or drop in the pre-built form:

import { CreateOrganization } from '@authn.sh/sdk-react';
<CreateOrganization afterCreateOrganizationUrl="/dashboard" />;

Signed-in users can create organizations from their Account Portal at /userOrganizations — no additional code needed if <UserProfile /> is mounted.

Members are invited by email. An invitation email is sent; clicking the link takes the recipient through sign-up (or sign-in if they already have an account) and then adds them as a member.

Terminal window
curl -X POST https://<FAPI_URL>/v1/organizations/org_01K.../invitations \
-H "Authorization: Bearer <secret_key>" \
-H "Content-Type: application/json" \
-d '{
"email_address": "kate@acme.com",
"role": "org:member",
"redirect_url": "https://app.acme.com/welcome"
}'
Authn::organizationInvitations($orgId)->create([
'email_address' => 'kate@acme.com',
'role' => 'org:member',
'redirect_url' => 'https://app.acme.com/welcome',
]);
import { useOrganizationActions } from '@authn.sh/sdk-react';
const { inviteMember } = useOrganizationActions();
await inviteMember({
emailAddress: 'kate@acme.com',
role: 'org:member',
});

Invitations expire after 30 days. Revoke early with DELETE /v1/organizations/{org_id}/invitations/{inv_id}/revoke.

Each member holds exactly one role. Change it with a PATCH on the membership:

Terminal window
curl -X PATCH https://<FAPI_URL>/v1/organizations/org_01K.../memberships/orgmem_01K... \
-H "Authorization: Bearer <secret_key>" \
-H "Content-Type: application/json" \
-d '{ "role": "org:admin" }'
Authn::organizationMemberships($orgId)->update($membershipId, [
'role' => 'org:admin',
]);
import { useOrganizationActions } from '@authn.sh/sdk-react';
const { updateMemberRole } = useOrganizationActions();
await updateMemberRole(membershipId, 'org:admin');

The caller must hold org:sys_memberships:manage in the same organization to change another member’s role.

A session has at most one active organization at a time — the one the user is currently operating as. Setting it embeds an org claim in the session JWT:

{
"sub": "user_01K…",
"org": {
"id": "org_01K…",
"slug": "acme",
"role": "org:admin",
"permissions": [
"org:sys_memberships:manage",
"org:sys_memberships:read",
"org:sys_profile:delete",
"org:sys_profile:manage",
"org:sys_domains:manage",
"org:sys_domains:read"
]
}
}

The SDK sets the active org automatically when the user switches organizations.

import { useActiveOrganization, useOrganizationList } from '@authn.sh/sdk-react';
function OrgSwitcher() {
const { organization } = useActiveOrganization();
const { setActive, organizationList } = useOrganizationList();
return (
<select
value={organization?.id ?? ''}
onChange={(e) => setActive({ organization: e.target.value })}
>
{organizationList.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
);
}

Or use the pre-built component:

import { OrganizationSwitcher } from '@authn.sh/sdk-react';
<OrganizationSwitcher />;

On the backend, read the active org from the verified JWT. See Verify JWTs in a backend for setup, then:

use AuthnSh\Sdk\Authn;
$claims = Authn::verifyToken($jwt);
// $claims->organization is populated only when a member is active in an org
if ($claims->organization?->hasPermission('org:sys_memberships:manage')) {
// allow
}

See Roles & Permissions for the full permission catalog.

Verified domains let you automate membership based on a user’s email domain. Once a domain is verified, one of three enrollment modes applies to new sign-ups:

ModeEffect
manual_invitationNo automatic action — members must be invited explicitly (the default).
automatic_invitationSign-ups whose email matches the domain skip the invitation step and join immediately as the default_role.
automatic_suggestionSign-ups with a matching email receive an OrganizationMembershipRequest that an admin must approve.
Terminal window
curl -X POST https://<FAPI_URL>/v1/organizations/org_01K.../domains \
-H "Authorization: Bearer <secret_key>" \
-H "Content-Type: application/json" \
-d '{
"name": "acme.com",
"enrollment_mode": "automatic_invitation"
}'
Authn::organizationDomains($orgId)->create([
'name' => 'acme.com',
'enrollment_mode' => 'automatic_invitation',
]);

Verification proves domain ownership via a Challenge resource. Two strategies are supported:

DNS TXT — the server creates a challenge and returns a nonce — the TXT value you must publish in your DNS zone. Your operator publishes the record, then polls GET /v1/organizations/{org_id}/domains/{domain_id}/challenges/{cid} until status: verified. There is no separate verify call; authn.sh resolves the record on the next poll and flips the status automatically.

Terminal window
curl -X POST https://<FAPI_URL>/v1/organizations/org_01K.../domains/orgdom_01K.../challenges \
-H "Authorization: Bearer <secret_key>" \
-H "Content-Type: application/json" \
-d '{ "strategy": "dns_txt" }'

The response includes:

{
"id": "cha_01K...",
"strategy": "dns_txt",
"nonce": "_authn-domain-verify.acme.com TXT \"authn_<nonce>\"",
"status": "pending"
}

Publish the TXT record, then poll:

Terminal window
curl https://<FAPI_URL>/v1/organizations/org_01K.../domains/orgdom_01K.../challenges/cha_01K... \
-H "Authorization: Bearer <secret_key>"

When the record resolves, status flips to verified and OrganizationDomain.verified becomes true. The polling approach is an improvement over a one-shot verify call — your operator can publish the record asynchronously and let the API confirm ownership without a synchronous HTTP round-trip.

Email code — an affiliation address at the domain (admin@acme.com) receives a 6-digit code. Create a challenge with strategy: email_code, then answer it with the code:

Terminal window
curl -X POST https://<FAPI_URL>/v1/organizations/org_01K.../domains/orgdom_01K.../challenges \
-H "Authorization: Bearer <secret_key>" \
-H "Content-Type: application/json" \
-d '{ "strategy": "email_code" }'
curl -X POST https://<FAPI_URL>/v1/organizations/org_01K.../domains/orgdom_01K.../challenges/cha_01K.../answer \
-H "Authorization: Bearer <secret_key>" \
-H "Content-Type: application/json" \
-d '{ "code": "123456" }'
$challenge = Authn::organizationDomainChallenges($orgId, $domainId)->create([
'strategy' => 'email_code',
]);
$challenge->answer(['code' => $request->input('code')]);

Once verified: true, the enrollment mode takes effect for future sign-ups.