go-auth
Guides

Organizations

Multi-tenant orgs — creating and managing them, membership and roles, active-org scoping, and invites. curl, Go, and a browser client.

Organizations

Multi-tenancy: users can belong to more than one organization, hold a different role in each, and scope a session to one of them at a time. Disabled by default — with WithOrganizations unset (or Enable: false), none of the routes on this page are mounted at all, and h.services.Org is nil.

For state-changing requests, every curl example below needs the same -H "Origin: https://myapp.com" treatment as Authentication — omitted from most snippets below for brevity, but required in practice.

Configuration

goauth.WithOrganizations(goauth.OrganizationConfig{
    Enable:         true,               // optional, default false
    MaxOrgsPerUser: 10,                 // optional, default 0 (built-in cap of 100)
    InviteTTL:      7 * 24 * time.Hour, // optional, default 7d
})
FieldTypeRequiredDefaultNotes
EnableboolOptionalfalseNothing on this page exists until this is true.
MaxOrgsPerUserintOptional0 — built-in cap of 100Counts orgs a user owns, not every org they're a member of. Enforced on both org creation and being granted/invited as Owner.
InviteTTLtime.DurationOptional7dHow long a code from Creating an invite stays valid.

Expected errors

  • organizations.max_orgs_per_user must be between 0 and 100 (0 = default 100)
  • organizations.invite_ttl must be positive — hint: only reachable via an explicit negative value.

Full reference: Configuration → WithOrganizations.

Frontend client setup

The Client examples below call the same apiRequest helper defined on Client → Setup.

Roles

Three roles, compared by weight, not by name — RequireOrgRole(min) at the HTTP layer checks role.Weight() >= min.Weight(), so "Admin or above" is really "weight 2 or above."

RoleWeightCan
owner3Everything, including deleting the org and granting/revoking Owner
admin2Manage members and invites, update org name/slug — everything except delete the org or touch Owner status
member1Read the org, leave it

Granting or revoking Owner requires being an Owner yourself

This is checked separately from the route-level RequireOrgRole, inside the service layer: Change a member's role and Creating an invite both special-case the owner role — an Admin can promote a Member to Admin (that's normal RequireOrgRole(admin) territory), but cannot self-escalate to Owner or hand Owner to anyone else. Only an existing Owner can create or remove another Owner. Attempting it as anything less returns org_forbidden, distinct from the route-level 403 you'd get for not being an Admin at all.

Every org-scoped route below runs through RequireOrgMember first (reads {orgID} from the path, 403 if you're not a member — indistinguishable from the org not existing, on purpose), then RequireOrgRole if the action needs more than plain membership.

Creating and reading orgs

Create an org — POST /auth/orgs

Auth: any authenticated user. Body: { "name", "slug" }. The caller becomes the org's first member, with role owner.

{
  "id": "a1b2...",
  "name": "Acme Inc",
  "slug": "acme",
  "created_by": "b3f1...",
  "owner_count": 1,
  "member_count": 1,
  "metadata": {},
  "created_at": "2026-08-09T12:00:00Z",
  "updated_at": "2026-08-09T12:00:00Z"
}
CodeStatusCause
invalid_name400name is empty
invalid_slug400slug is over 255 bytes
org_slug_reserved400slug is one of the reserved words (api, admin, auth, www, ... — see Configuration for the full list)
org_slug_exists409Another org already has that slug
org_limit_reached400Caller already owns MaxOrgsPerUser orgs

curl

curl -X POST https://api.myapp.com/auth/orgs \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b cookies.txt -c cookies.txt \
  -d '{"name":"Acme Inc","slug":"acme"}'

Programmatic (Go)

org, err := auth.Services.Org.CreateOrg(ctx, service.CreateOrgInput{
    Name:    "Acme Inc",
    Slug:    "acme",
    OwnerID: userID,
})

Client

const org = await apiRequest(API_BASE, "POST", "/auth/orgs", { name: "Acme Inc", slug: "acme" });

List your orgs — GET /auth/orgs

Auth: any authenticated user. No params — every org the caller belongs to, any role.

{ "orgs": [ { "...": "same shape as create, one per org" } ] }

curl

curl https://api.myapp.com/auth/orgs -b cookies.txt -c cookies.txt

Programmatic (Go)

orgs, err := auth.Services.Org.ListUserOrgs(ctx, userID)

Client

const { orgs } = await apiRequest(API_BASE, "GET", "/auth/orgs");

Get one org — GET /auth/orgs/{orgID}

Auth: org member. Same response shape as create.

CodeStatusCause
org_not_found404Doesn't exist
forbidden403Exists, but the caller isn't a member — same status either way, so membership can't be probed

curl

curl https://api.myapp.com/auth/orgs/a1b2... -b cookies.txt -c cookies.txt

Programmatic (Go)

org, err := auth.Services.Org.GetByID(ctx, orgID)

Client

const org = await apiRequest(API_BASE, "GET", `/auth/orgs/${orgId}`);

Updating and deleting orgs

Update an org — PUT /auth/orgs/{orgID}

Auth: org admin or above. Body: { "name"?, "slug"? } — both pointers, so omitting a field leaves it unchanged rather than clearing it. This is the same wholesale-vs-partial distinction as elsewhere in the library, just resolved the other way: unlike WithRegistration, this one really is a partial update.

{ "...": "the updated org, same shape as create" }
CodeStatusCause
org_not_found404Doesn't exist
org_slug_reserved400New slug is a reserved word
org_slug_exists409New slug is already taken by another org

curl

curl -X PUT https://api.myapp.com/auth/orgs/a1b2... \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b cookies.txt -c cookies.txt \
  -d '{"name":"Acme Incorporated"}'

Programmatic (Go)

newName := "Acme Incorporated"
org, err := auth.Services.Org.UpdateOrg(ctx, service.UpdateOrgInput{
    OrgID: orgID,
    Name:  &newName, // nil Slug leaves the slug unchanged
})

Client

const org = await apiRequest(API_BASE, "PUT", `/auth/orgs/${orgId}`, { name: "Acme Incorporated" });

Delete an org — DELETE /auth/orgs/{orgID}

Auth: org owner. Deletes the org outright — no soft-delete. Every member's active_org_id pointing at it is cleared first (their sessions survive; they're just no longer scoped to a now-gone org).

{ "message": "Organization deleted" }

The only error beyond the standard auth ones is org_not_found (404).


curl

curl -X DELETE https://api.myapp.com/auth/orgs/a1b2... \
  -H "Origin: https://myapp.com" \
  -b cookies.txt -c cookies.txt

Programmatic (Go)

err := auth.Services.Org.DeleteOrg(ctx, orgID)

Client

await apiRequest(API_BASE, "DELETE", `/auth/orgs/${orgId}`);

Members

List members — GET /auth/orgs/{orgID}/members

Auth: org member. Query: offset (default 0), limit (default 20, max 100 — same clamping as every other list endpoint on this site, applied in OrgService.ListMembers).

{
  "members": [
    {
      "org_id": "a1b2...",
      "user_id": "b3f1...",
      "role": "owner",
      "joined_at": "2026-08-01T09:00:00Z",
      "user": { "...": "the full domain.User for this member" }
    }
  ],
  "total": 1
}

curl

curl "https://api.myapp.com/auth/orgs/a1b2.../members?offset=0&limit=20" -b cookies.txt -c cookies.txt

Programmatic (Go)

members, total, err := auth.Services.Org.ListMembers(ctx, orgID, 0, 20)

Client

const { members, total } = await apiRequest(
  API_BASE, "GET", `/auth/orgs/${orgId}/members?offset=0&limit=20`
);

Remove a member, or leave — DELETE /auth/orgs/{orgID}/members/{userID}, POST /auth/orgs/{orgID}/leave

Two routes, one underlying operation: LeaveOrg is literally RemoveMember(orgID, callerID) — leaving is just removing yourself. Removing someone else requires org admin; leaving only requires being a member (you can always remove yourself).

{ "message": "Member removed" }

or, from /leave:

{ "message": "Left organization" }
CodeStatusCause
org_member_not_found404That user isn't a member
cannot_remove_last_owner400Target is the org's only Owner — removing (or leaving as) the last Owner is blocked so an org can never end up with zero owners

curl

# Remove someone else (admin+)
curl -X DELETE https://api.myapp.com/auth/orgs/a1b2.../members/b3f1... \
  -H "Origin: https://myapp.com" -b cookies.txt -c cookies.txt

# Leave it yourself
curl -X POST https://api.myapp.com/auth/orgs/a1b2.../leave \
  -H "Origin: https://myapp.com" -b cookies.txt -c cookies.txt

Programmatic (Go)

err := auth.Services.Org.RemoveMember(ctx, orgID, targetUserID)
// leaving is the same call with your own ID:
err = auth.Services.Org.LeaveOrg(ctx, orgID, callerUserID)

Client

await apiRequest(API_BASE, "DELETE", `/auth/orgs/${orgId}/members/${userId}`);
await apiRequest(API_BASE, "POST", `/auth/orgs/${orgId}/leave`);

Change a member's role — PATCH /auth/orgs/{orgID}/members/{userID}/role

Auth: org admin — except granting or revoking owner, which requires the caller to already be an Owner (see the Roles callout above). Body: { "role" }.

{ "message": "Role updated" }
CodeStatusCause
invalid_role400role isn't owner/admin/member
org_member_not_found404Target isn't a member
org_forbidden403Caller is an Admin (not Owner) trying to grant or revoke owner
cannot_remove_last_owner400Target is the sole Owner and the new role isn't owner

curl

curl -X PATCH https://api.myapp.com/auth/orgs/a1b2.../members/b3f1.../role \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b cookies.txt -c cookies.txt \
  -d '{"role":"admin"}'

Programmatic (Go)

err := auth.Services.Org.UpdateMemberRole(ctx, service.UpdateMemberRoleInput{
    OrgID:   orgID,
    UserID:  targetUserID,
    NewRole: domain.OrgRoleAdmin,
    ActorID: callerUserID, // whose own membership gets checked for the Owner-only rule above
})

Client

await apiRequest(API_BASE, "PATCH", `/auth/orgs/${orgId}/members/${userId}/role`, { role: "admin" });

Active org

A session can be scoped to one org at a time — stored as sessions.active_org_id/active_org_role (see Schemas → sessions), returned on every session-listing response from Sessions. Your app reads it to decide which org's data the current request should see; go-auth itself doesn't use it to filter anything — that's on you.

Reading it back

In your own Go handlers, if they're wrapped by AuthMiddleware, the active org rides along on the session already attached to the request context — no extra call needed:

func myHandler(w http.ResponseWriter, r *http.Request) {
    session := middleware.GetSessionFromContext(r.Context())
    if session.ActiveOrgID == nil {
        // no active org set — decide your own fallback (all orgs? a default org? an error?)
        return
    }
    // scope your query by *session.ActiveOrgID
    // session.ActiveOrgRole is also set, so you can gate by role without a
    // separate GetMembership lookup
}

Don't confuse this with GetOrgID / GetOrgRole

middleware.GetOrgID(ctx) and middleware.GetOrgRole(ctx) are a different mechanism — they're set by RequireOrgMember/RequireOrgRole from the {orgID} in the URL, for go-auth's own org-scoped routes on this page (everything above this section). session.ActiveOrgID/ActiveOrgRole from GetSessionFromContext is the user's session-level "current org," set via the two endpoints below, and it only means something to your routes — go-auth's own handlers never read it. A request to GET /auth/orgs/{orgID} and a request relying on the active org can legitimately disagree about which org they're talking about; they're independent.

From a browser client, there's no dedicated "what's my active org" endpoint — /auth/me returns the user, not the session. The active org is visible on the session-listing responses (match current_session_id against the sessions array to find your own), but the simplest approach is usually to just track it as local state the moment you call setActive/clearActive below, rather than re-fetching to confirm it.

Set active org — PUT /auth/orgs/active

Auth: any authenticated user. Body: { "orgId" }. No {orgID} path segment — membership is checked inside the service, not by RequireOrgMember middleware.

{ "message": "Active org updated" }

The only error beyond the standard auth ones is org_member_not_found (404) — you can't activate an org you don't belong to.


curl

curl -X PUT https://api.myapp.com/auth/orgs/active \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b cookies.txt -c cookies.txt \
  -d '{"orgId":"a1b2..."}'

Programmatic (Go)

err := auth.Services.Org.SetActiveOrg(ctx, sessionID, userID, orgID)

Client

await apiRequest(API_BASE, "PUT", "/auth/orgs/active", { orgId });

Clear active org — DELETE /auth/orgs/active

Auth: any authenticated user. No body.

{ "message": "Active org cleared" }

curl

curl -X DELETE https://api.myapp.com/auth/orgs/active \
  -H "Origin: https://myapp.com" -b cookies.txt -c cookies.txt

Programmatic (Go)

err := auth.Services.Org.ClearActiveOrg(ctx, sessionID, orgID)

Client

await apiRequest(API_BASE, "DELETE", "/auth/orgs/active");

Invites

Email-based, code-authenticated, and matched by email on accept — the accepting account's email must equal the invite's, so an invite can't be redeemed by whoever happens to have the link.

Creating an invite — POST /auth/orgs/{orgID}/invites

Auth: org admin — except inviting someone directly as owner, which requires the caller to already be an Owner, mirroring Change a member's role. Body: { "email", "role" }. Requires a mailer (WithMailer/WithEmail) — same requirement as email verification; without one, this fails outright rather than silently skipping delivery.

{
  "id": "c4d5...",
  "org_id": "a1b2...",
  "email": "new-hire@example.com",
  "role": "member",
  "rawCode": "the-raw-code",
  "invited_by": "b3f1...",
  "expires_at": "2026-08-16T12:00:00Z",
  "created_at": "2026-08-09T12:00:00Z"
}

rawCode only appears here, on creation — List invites omits it, since by then it's only useful to whoever received the email.

CodeStatusCause
invalid_role400role isn't owner/admin/member
org_forbidden403Caller is an Admin (not Owner) inviting as owner
email_failed500Invite row was created, but sending the email failed
internal_error500InviteTTL isn't configured (WithOrganizations wasn't set up with a positive InviteTTL)

curl

curl -X POST https://api.myapp.com/auth/orgs/a1b2.../invites \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b cookies.txt -c cookies.txt \
  -d '{"email":"new-hire@example.com","role":"member"}'

Programmatic (Go)

invite, err := auth.Services.OrgInvite.CreateOrgInvite(ctx, service.CreateOrgInviteInput{
    OrgID:     orgID,
    Email:     "new-hire@example.com",
    Role:      domain.OrgRoleMember,
    InvitedBy: callerUserID,
})

Client

const invite = await apiRequest(API_BASE, "POST", `/auth/orgs/${orgId}/invites`, {
  email: "new-hire@example.com",
  role: "member",
});

Accepting an invite — POST /auth/orgs/invites/accept

Auth: any authenticated user — the invited person has to already have (or just have created) an account; this only adds them to the org. Body: { "code" }. No {orgID} segment — the org comes from the code itself.

{ "message": "Invite accepted" }
CodeStatusCause
invalid_code400code was empty
org_invite_expired400Code doesn't match any invite, or matches one that's already been claimed or has expired — all three collapse to this one code
org_invite_email_mismatch400The authenticated caller's email doesn't match the invite's — log in (or sign up) as the invited address first

curl

curl -X POST https://api.myapp.com/auth/orgs/invites/accept \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b cookies.txt -c cookies.txt \
  -d '{"code":"the-raw-code"}'

Programmatic (Go)

err := auth.Services.OrgInvite.AcceptInvite(ctx, service.AcceptInviteInput{
    UserID:  callerUserID,
    RawCode: code,
})

Client

await apiRequest(API_BASE, "POST", "/auth/orgs/invites/accept", { code });

Listing invites — GET /auth/orgs/{orgID}/invites

Auth: org admin. No pagination — every invite for the org, regardless of status, in one response. rawCode is never present here (see Creating an invite).

{ "invites": [ { "...": "same shape as create, minus rawCode" } ] }

curl

curl https://api.myapp.com/auth/orgs/a1b2.../invites -b cookies.txt -c cookies.txt

Programmatic (Go)

invites, err := auth.Services.OrgInvite.ListOrgInvites(ctx, orgID)

Client

const { invites } = await apiRequest(API_BASE, "GET", `/auth/orgs/${orgId}/invites`);

Resending or deleting an invite — POST .../invites/{inviteID}/resend, DELETE .../invites/{inviteID}

Auth: org admin for both. orgID in the path is only used for the membership/role check — the handler looks up the invite by inviteID alone, so it isn't re-validated against orgID a second time inside the handler itself.

{ "message": "Invite email resent" }

or:

{ "message": "Invite deleted" }

Delete here is a hard delete of the invite record — distinct from revoking a self-service signup invite (see Routes → Invites), which is a different feature with its own soft-cancel semantics.


curl

curl -X POST https://api.myapp.com/auth/orgs/a1b2.../invites/c4d5.../resend \
  -H "Origin: https://myapp.com" -b cookies.txt -c cookies.txt

curl -X DELETE https://api.myapp.com/auth/orgs/a1b2.../invites/c4d5... \
  -H "Origin: https://myapp.com" -b cookies.txt -c cookies.txt

Programmatic (Go)

err := auth.Services.OrgInvite.ResendOrgInviteEmail(ctx, inviteID)
err = auth.Services.OrgInvite.DeleteOrgInvite(ctx, inviteID)

Client

await apiRequest(API_BASE, "POST", `/auth/orgs/${orgId}/invites/${inviteId}/resend`);
await apiRequest(API_BASE, "DELETE", `/auth/orgs/${orgId}/invites/${inviteId}`);

Next

  • Routes — the full path/param table for every route on this page
  • Schemas — the organizations, organization_members, and organization_invites tables
  • Authentication — the mailer requirement invites share with email verification
  • Sessions — where active_org_id/active_org_role show up on a session

On this page