DEV Community

Cover image for Build multi-tenant auth in Go with Limen
Brian Iyoha
Brian Iyoha

Posted on • Originally published at limenauth.dev

Build multi-tenant auth in Go with Limen

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, and member
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  • owner can do everything in the organization, plus all project and billing actions
  • admin can manage projects and read billing
  • member can 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:

  1. Require a signed-in user with auth.GetSession(r).
  2. Find the organization the user is working in.
  3. Check the required permission with orgAPI.HasPermission(...).
  4. 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Set your environment:

export DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
export LIMEN_SECRET="$(openssl rand -hex 16)"
Enter fullscreen mode Exit fullscreen mode

Start the app:

go run .
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()],
})
Enter fullscreen mode Exit fullscreen mode

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',
})
Enter fullscreen mode Exit fullscreen mode

Now create an organization:

const organization = await auth.organization.create({
  name: 'Acme',
  slug: 'acme',
})
Enter fullscreen mode Exit fullscreen mode

Creating an organization does three things:

  • creates the organization
  • makes the current user a member
  • gives that user the owner role.

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()
Enter fullscreen mode Exit fullscreen mode

Switch to another organization:

await auth.organization.switch({ id: organization.id })
Enter fullscreen mode Exit fullscreen mode

Only members can switch into an organization. Pass null to clear the selected organization:

await auth.organization.switch({ id: null })
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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',
})
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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)