Many SaaS products are organized around workspaces, teams, or customer accounts. In those apps, authentication is only one part of the tenancy model. You also need a way to create organizations, manage membership, switch the active workspace, scope data to the right tenant, and decide what each member can do.
In this guide, each organization acts as a tenant. The same user may belong to several organizations, and each organization has its own projects, members, invitations, roles, and product data. For each organization-scoped action, you need to know:
- a signed-in user
- the organization they are working in
- whether their role allows the action.
Limen's Organization plugin gives your Go app the pieces for that flow: organizations, members, roles, invitations, organization switching, and Go APIs for tenant-aware handlers.
We'll use projects as the running example: each project is created inside the user's selected organization.
What we are building
The app will support:
- email/password sign-up and sign-in
- organizations as tenants
- switching between a user's organizations
- organization invitations
- tenant-owned project data scoped by organization
- tenant-scoped roles:
owner,admin, andmember - app-specific permissions such as
project:create - a protected Go endpoint that refuses to create a project unless the current user can do it in the selected organization.
You'll need Go 1.25+ and Postgres. If you want to follow the frontend snippets, you'll also need Node 20+.
If you want a local database quickly:
docker run --name limen-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres
Create a new Go module and install Limen, the SQL adapter, email/password auth, and organizations:
mkdir limen-multitenant && cd limen-multitenant
go mod init example.com/limen-multitenant
go get github.com/thecodearcher/limen
go get github.com/thecodearcher/limen/adapters/sql
go get github.com/thecodearcher/limen/plugins/credential-password
go get github.com/thecodearcher/limen/plugins/organization
go get github.com/lib/pq
Wire Limen into a Go server
Create main.go:
package main
import (
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
"os"
_ "github.com/lib/pq"
"github.com/thecodearcher/limen"
"github.com/thecodearcher/limen/access"
sqladapter "github.com/thecodearcher/limen/adapters/sql"
credentialpassword "github.com/thecodearcher/limen/plugins/credential-password"
organization "github.com/thecodearcher/limen/plugins/organization"
)
func main() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
auth, err := newAuth(db)
if err != nil {
log.Fatal(err)
}
orgAPI := organization.Use(auth)
mux := http.NewServeMux()
mux.Handle("/auth/", auth.Handler())
mux.HandleFunc("POST /api/projects", createProject(auth, orgAPI))
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
func newAuth(db *sql.DB) (*limen.Limen, error) {
ac := access.New(access.MergeStatements(
organization.DefaultStatements(),
access.Statements{
"project": {"create", "read", "update", "delete"},
"billing": {"read", "update"},
},
))
roles, err := tenantRoles(ac)
if err != nil {
return nil, err
}
return limen.New(&limen.Config{
Database: sqladapter.NewPostgreSQL(db),
CLI: &limen.CLIConfig{Enabled: true},
Plugins: []limen.Plugin{
credentialpassword.New(),
organization.New(
organization.WithAccessControl(ac),
organization.WithRoles(roles...),
organization.WithSlugNormalization(true),
organization.WithMaxOrgPerUser(5),
organization.WithSendInvitationMail(func(ctx context.Context, data *organization.SendInvitationMailData) {
inviteURL := "http://localhost:3000/invitations/" + data.Invitation.Token
log.Printf("invite %s to %s: %s", data.Invitation.Email, data.Organization.Name, inviteURL)
}),
),
},
})
}
func tenantRoles(ac *access.AccessControl) ([]access.Role, error) {
owner, err := organization.DefaultOwnerRole(ac, access.P("project:*", "billing:*"))
if err != nil {
return nil, err
}
admin, err := organization.DefaultAdminRole(ac, access.P("project:*", "billing:read"))
if err != nil {
return nil, err
}
member, err := organization.DefaultMemberRole(ac, access.P("project:read"))
if err != nil {
return nil, err
}
return []access.Role{owner, admin, member}, nil
}
Two plugins do most of the setup:
-
credentialpassword.New()handles sign-up and sign-in. -
organization.New(...)handles tenants, memberships, invitations, roles, and organization switching.
The role setup also includes the application actions we want to protect: project and billing. That lets organization roles grant access to product features, not only organization management.
Then we extend the default organization roles:
-
ownercan do everything in the organization, plus all project and billing actions -
admincan manage projects and read billing -
membercan read projects only.
Those permissions are scoped per organization. A user can be an owner in one organization and only a member in another.
Protect product actions
Limen handles authentication and organization management. For product actions like creating a project, your Go code should confirm which organization the user is working in, check the permission, and keep the project inside that organization.
The pattern is:
- Require a signed-in user with
auth.GetSession(r). - Find the organization the user is working in.
- Check the required permission with
orgAPI.HasPermission(...). - Create or read records only inside that organization.
Add this below tenantRoles:
func createProject(auth *limen.Limen, orgAPI organization.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, err := auth.GetSession(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
organizationID, err := orgAPI.GetActiveOrganizationID(r.Context(), session.Session)
if err != nil || organizationID == nil {
http.Error(w, "Select an organization first", http.StatusBadRequest)
return
}
if err := orgAPI.HasPermission(r.Context(), session.User, organizationID, access.P("project:create")); err != nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name == "" {
http.Error(w, "Project name is required", http.StatusBadRequest)
return
}
// Persist the project with organizationID in your own app database.
project := map[string]any{
"name": body.Name,
"organization_id": organizationID,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(project)
}
}
For project creation, tenant scoping belongs to the app API. The frontend sends the project name, and POST /api/projects creates it in the organization the user is working in.
For list and update endpoints, use the same pattern:
if err := orgAPI.HasPermission(ctx, session.User, organizationID, access.P("project:read")); err != nil {
return err
}
// SELECT * FROM projects WHERE organization_id = organizationID
Run migrations
Enable the CLI with CLI: &limen.CLIConfig{Enabled: true} as shown above. Then install the CLI:
go install github.com/thecodearcher/limen/cmd/limen@latest
Set your environment:
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
export LIMEN_SECRET="$(openssl rand -hex 16)"
Start the app:
go run .
Leave it running. In another terminal, generate migrations:
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
limen generate migrations --driver postgres --dsn "$DATABASE_URL"
Apply the generated SQL with your migration tool before using the app.
Set up the TypeScript client
Install the SDK in your frontend:
npm install limen-auth
Create src/auth-client.ts:
import { createAuthClient } from 'limen-auth/react'
import { credentialPasswordPlugin, organizationPlugin } from 'limen-auth/plugins'
export const auth = createAuthClient({
baseURL: 'http://localhost:8080',
plugins: [credentialPasswordPlugin(), organizationPlugin()],
})
The rest of the frontend calls in this guide use that auth client.
Create a user and organization
Create the user with the credential-password plugin:
import { auth } from './auth-client'
await auth.signUp.credential({
email: 'ada@example.com',
password: 'Correct-horse-1',
})
Now create an organization:
const organization = await auth.organization.create({
name: 'Acme',
slug: 'acme',
})
Creating an organization does three things:
- creates the organization
- makes the current user a member
- gives that user the
ownerrole.
After creation, the user is working in that organization.
Switch between tenants
A user can belong to multiple organizations. List the organizations they belong to:
const organizations = await auth.organization.list()
Switch to another organization:
await auth.organization.switch({ id: organization.id })
Only members can switch into an organization. Pass null to clear the selected organization:
await auth.organization.switch({ id: null })
Now create a project through your app API. The Limen SDK handles auth state, and /api/projects belongs to your app:
async function createProject(name: string) {
const response = await fetch('http://localhost:8080/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!response.ok) {
throw new Error(await response.text())
}
return response.json()
}
await createProject('Customer portal')
If the user has not selected an organization, they get 400. If they are working in an organization but lack project:create, they get 403. Otherwise, your handler can safely create the project under the selected organization.
Invite teammates
Owners and admins can invite people into the organization they are working in:
await auth.organization.invite({
email: 'grace@example.com',
role: 'member',
})
When an invitation is created, the server example logs a local invite URL. In production, send the invite link with your email provider.
The invited user signs in with the invited email address and accepts:
const invitationToken = new URL(window.location.href).pathname.split('/').pop()
if (!invitationToken) throw new Error('Missing invitation token')
await auth.signIn.credential({
credential: 'grace@example.com',
password: 'Correct-horse-1',
})
await auth.organization.acceptInvitation({
token: invitationToken,
})
After acceptance, the invited user joins with the selected role. They can then switch into the organization and use only the permissions that role grants:
const joinedOrganizations = await auth.organization.list()
await auth.organization.switch({ id: joinedOrganization.id })
Gate UI from the user's membership:
import { can } from 'limen-auth'
import { auth } from './auth-client'
export function CreateProjectButton() {
const { data: membership, isPending } = auth.useActiveMembership()
if (isPending) return null
if (!can(membership, 'project:create')) return null
return <button type="button">New project</button>
}
Use client checks to keep the interface tidy. Keep the real authorization in Go with orgAPI.HasPermission(...).
Where to go next
This guide keeps the example small: users, organizations, invitations, switching, projects, and role checks.
For the full organization API, see the Organization plugin docs.
For integrations and automation, add the API Key plugin and the organization-scoped API key flow.
Top comments (0)