Skip to main content
Stack Auth provides built-in multi-tenancy through Teams, allowing users to be members of multiple organizations with different roles and permissions in each. This guide explains the team system and how to use it.

Team Concepts

What is a Team?

A Team represents a group of users working together within your application. Teams enable:
  • Multi-tenancy (users belong to multiple organizations)
  • Team-specific data isolation
  • Role-based access within teams
  • Collaborative features
Key characteristics:
  • Each team has a unique ID across the project
  • Teams are associated with a specific branch (usually “main”)
  • Teams can have custom metadata (client, server, read-only)
  • Users can be members of multiple teams simultaneously

Team Membership

TeamMember represents a user’s membership in a specific team:
  • Links a ProjectUser to a Team
  • Can override user’s display name and profile image for that team
  • Stores team-specific permissions
  • Tracks whether this team is the user’s selected team

Selected Team

Users can have one selected team at a time:
  • Used as the default team for operations
  • Included in JWT access token (selected_team_id)
  • Unique constraint ensures only one selected team per user
  • Can be updated via API
See implementation at /apps/backend/prisma/schema.prisma:143.

Team Operations

Creating a Team

Create a new team with the current user as the creator:
POST /api/v1/teams
{
  "display_name": "Acme Corp",
  "creator_user_id": "me",
  "profile_image_url": "data:image/png;base64,...",
  "client_metadata": { "industry": "technology" }
}
Response:
{
  "id": "team_abc123",
  "display_name": "Acme Corp",
  "profile_image_url": "https://...",
  "created_at_millis": 1704067200000,
  "client_metadata": { "industry": "technology" },
  "client_read_only_metadata": null,
  "server_metadata": null
}
1

Team creation

A Team record is created with:
  • Unique team ID
  • Display name
  • Profile image (uploaded to S3 if base64)
  • Metadata fields
  • Mirrored project/branch IDs for uniqueness constraints
2

Creator added as member

If creator_user_id is provided, a TeamMember record is created linking the user to the team with type: 'creator'.
3

Default permissions granted

Team creator receives default permissions from config:
await grantDefaultTeamPermissions(tx, {
  tenancy,
  userId: addUserId,
  teamId: db.teamId,
  type: 'creator'
});
Typically includes:
  • $update_team
  • $delete_team
  • $invite_members
  • $remove_members
  • $manage_api_keys
4

Webhook fired

A team.created webhook is sent to configured endpoints with the team data.
See implementation at /apps/backend/src/app/api/latest/teams/crud.tsx:38.
With client authentication, users can only create teams if allowClientTeamCreation is enabled in the project configuration. With server/admin authentication, teams can always be created.

Reading a Team

Fetch team details by ID:
GET /api/v1/teams/{team_id}
Authorization:
  • Client auth: User must be a member of the team
  • Server/admin auth: Any team can be accessed
Membership verification (client auth):
if (auth.type === 'client') {
  await ensureTeamMembershipExists(prisma, {
    tenancyId: auth.tenancy.id,
    teamId: params.team_id,
    userId: auth.user?.id ?? throwErr(new KnownErrors.UserAuthenticationRequired)
  });
}
See implementation at /apps/backend/src/app/api/latest/teams/crud.tsx:110.

Updating a Team

Modify team information:
PATCH /api/v1/teams/{team_id}
{
  "display_name": "Acme Corporation",
  "client_metadata": { "industry": "technology", "size": "medium" }
}
Authorization:
  • Client auth: User must have $update_team permission (recursively checked)
  • Server/admin auth: No permission check required
Permission check (client auth):
await ensureUserTeamPermissionExists(tx, {
  tenancy: auth.tenancy,
  teamId: params.team_id,
  userId: auth.user?.id,
  permissionId: "$update_team",
  errorType: 'required',
  recursive: true
});
See implementation at /apps/backend/src/app/api/latest/teams/crud.tsx:136.

Deleting a Team

Remove a team and all associated data:
DELETE /api/v1/teams/{team_id}
Authorization:
  • Client auth: User must have $delete_team permission
  • Server/admin auth: No permission check required
Cascading deletes: When a team is deleted:
  • All TeamMember records are deleted
  • All TeamMemberDirectPermission records are deleted
  • All ProjectApiKey records for the team are deleted
  • Team-specific data is removed
See implementation at /apps/backend/src/app/api/latest/teams/crud.tsx:182.

Listing Teams

Retrieve teams (optionally filtered by user membership):
// List all teams (admin only)
GET /api/v1/teams

// List teams for a specific user
GET /api/v1/teams?user_id=user_abc123

// List teams for current user (client auth)
GET /api/v1/teams?user_id=me
Response:
{
  "items": [
    {
      "id": "team_abc123",
      "display_name": "Acme Corp",
      "profile_image_url": "https://...",
      "created_at_millis": 1704067200000,
      "client_metadata": {},
      "client_read_only_metadata": null,
      "server_metadata": null
    }
  ],
  "is_paginated": false
}
Authorization:
  • Client auth: Must filter by current user (user_id=me)
  • Server/admin auth: Can list all teams or filter by any user
See implementation at /apps/backend/src/app/api/latest/teams/crud.tsx:214.

Team Memberships

Adding Users to Teams

Add a user to a team (invitation flow):
POST /api/v1/teams/{team_id}/users/{user_id}
This creates a TeamMember record and grants default member permissions. Implementation:
await addUserToTeam(tx, {
  tenancy: auth.tenancy,
  teamId: team.id,
  userId: user.id,
  type: "member"  // or "creator"
});
The type parameter determines which default permissions are granted:
  • "creator" - Gets team creator permissions
  • "member" - Gets team member permissions
See usage at /apps/backend/src/app/api/latest/teams/crud.tsx:90.

Team Member Profiles

Each team membership can have customized profile information:
PATCH /api/v1/team-member-profiles/{team_id}/{user_id}
{
  "display_name": "John (Product Manager)",
  "profile_image_url": "data:image/png;base64,..."
}
TeamMember fields:
  • displayName - Overrides user’s global display name within this team
  • profileImageUrl - Overrides user’s global profile image within this team
If these fields are null, the user’s global profile information is used. See model at /apps/backend/prisma/schema.prisma:143.

Selected Team

Users can mark one team as their “selected” team:
POST /api/v1/team-memberships/select
{
  "team_id": "team_abc123"
}
This:
  1. Sets isSelected = TRUE on the specified TeamMember
  2. Unsets isSelected on all other memberships for this user
  3. Updates the user’s access token to include selected_team_id
Unique constraint:
model TeamMember {
  isSelected BooleanTrue?
  
  @@unique([tenancyId, projectUserId, isSelected])
}
The BooleanTrue enum (only values: TRUE) ensures only one selected team per user. See model at /apps/backend/prisma/schema.prisma:157.

Team Permissions

System Team Permissions

Stack Auth includes built-in team permissions:
{
  "$update_team": "Update the team information",
  "$delete_team": "Delete the team",
  "$read_members": "Read and list the other members of the team",
  "$remove_members": "Remove other members from the team",
  "$invite_members": "Invite other users to the team",
  "$manage_api_keys": "Create and manage API keys for the team"
}
These permissions:
  • Cannot be modified or deleted
  • Are available in all projects
  • Are granted via default permission configuration
See definition at /apps/backend/src/lib/permissions.tsx:13.

Custom Team Permissions

Define custom team-scoped permissions in your project config:
POST /api/v1/team-permission-definitions
{
  "id": "projects:manage",
  "description": "Manage team projects",
  "contained_permission_ids": ["projects:read", "projects:write"]
}
These permissions:
  • Must have scope: "team"
  • Can contain other team permissions
  • Are granted per team membership
  • Cannot contain project-scoped permissions

Granting Team Permissions

Grant a permission to a user within a specific team:
POST /api/v1/team-permissions
{
  "team_id": "team_abc123",
  "user_id": "user_xyz789",
  "permission_id": "$invite_members"
}
This creates a TeamMemberDirectPermission record. Requirements:
  • User must be a member of the team
  • Permission must exist and have scope: "team"
  • System permissions (prefixed with $) are always available

Checking Team Permissions

Verify a user has a specific team permission:
const hasPermission = await ensureUserTeamPermissionExists(tx, {
  tenancy,
  teamId: "team_abc123",
  userId: "user_xyz789",
  permissionId: "$update_team",
  errorType: 'required',  // Throws error if missing
  recursive: true  // Check contained permissions
});
With recursive: true, the check includes:
  1. Direct permission grants
  2. Permissions contained by granted permissions
  3. Full permission hierarchy
See usage at /apps/backend/src/app/api/latest/teams/crud.tsx:144.

Team Invitations

Invite users to join teams via email:

Creating an Invitation

POST /api/v1/team-invitations
{
  "team_id": "team_abc123",
  "email": "[email protected]"
}
This:
  1. Creates a VerificationCode with type TEAM_INVITATION
  2. Sends an email with the invitation code
  3. Returns the code (for testing) or just success
Authorization:
  • Client auth: User must have $invite_members permission
  • Server/admin auth: No permission check

Accepting an Invitation

POST /api/v1/team-invitations/accept
{
  "code": "ABC123"
}
This:
  1. Validates the verification code
  2. Creates user account if needed
  3. Adds user to the team as a member
  4. Grants default team member permissions
  5. Marks the code as used

Team API Keys

Teams can have their own API keys for programmatic access:

Creating Team API Keys

POST /api/v1/api-keys
{
  "team_id": "team_abc123",
  "description": "Production API Key",
  "expires_at": "2025-12-31T23:59:59Z"
}
This creates a ProjectApiKey record associated with the team. Authorization:
  • Client auth: User must have $manage_api_keys permission
  • Server/admin auth: No permission check

Using Team API Keys

Team API keys authenticate requests on behalf of the team:
Authorization: Bearer team_api_key_...
The key provides:
  • Access to team resources
  • Team-scoped operations
  • No individual user context

Data Model

Team Table

model Team {
  tenancyId String
  teamId    String  // Unique team ID
  
  // Uniqueness tracking
  mirroredProjectId String
  mirroredBranchId  String
  
  // Team information
  displayName            String
  profileImageUrl        String?
  clientMetadata         Json?
  clientReadOnlyMetadata Json?
  serverMetadata         Json?
  
  // Timestamps
  createdAt DateTime
  updatedAt DateTime
  
  // Relations
  teamMembers   TeamMember[]
  projectApiKey ProjectApiKey[]
  
  @@id([tenancyId, teamId])
  @@unique([mirroredProjectId, mirroredBranchId, teamId])
}

TeamMember Table

model TeamMember {
  tenancyId     String
  projectUserId String
  teamId        String
  
  // Team-specific profile
  displayName     String?
  profileImageUrl String?
  
  // Selected team flag
  isSelected BooleanTrue?
  
  // Timestamps
  createdAt DateTime
  updatedAt DateTime
  
  // Relations
  projectUser                 ProjectUser
  team                        Team
  teamMemberDirectPermissions TeamMemberDirectPermission[]
  
  @@id([tenancyId, projectUserId, teamId])
  @@unique([tenancyId, projectUserId, isSelected])
  @@index([tenancyId, projectUserId, isSelected])
}

TeamMemberDirectPermission Table

model TeamMemberDirectPermission {
  id            String
  tenancyId     String
  projectUserId String
  teamId        String
  permissionId  String  // e.g., "$update_team"
  
  createdAt DateTime
  updatedAt DateTime
  
  teamMember TeamMember @relation(...)
  
  @@id([tenancyId, id])
  @@unique([tenancyId, projectUserId, teamId, permissionId])
}
See full schema at /apps/backend/prisma/schema.prisma:111.

Multi-Tenancy Patterns

Team Isolation

Data can be isolated per team using tenancy patterns:
// Query data for a specific team
const teamData = await prisma.teamData.findMany({
  where: {
    tenancyId: auth.tenancy.id,
    teamId: currentTeam.id
  }
});

Selected Team Context

Use the selected team from the access token:
const accessToken = await decodeAccessToken(token, { 
  allowAnonymous: false,
  allowRestricted: false 
});

const selectedTeamId = accessToken.payload.selected_team_id;

if (!selectedTeamId) {
  throw new Error("No team selected");
}

// Use selectedTeamId for team-scoped operations

Cross-Team Operations

When users need to access multiple teams:
// List all teams user is a member of
const teams = await listTeams({
  userId: currentUser.id
});

// Verify access to specific team
const hasMembership = await isTeamMember({
  userId: currentUser.id,
  teamId: targetTeamId
});

if (!hasMembership) {
  throw new Error("User is not a member of this team");
}

Best Practices

Team Creation

  • Always add the creator as the first team member
  • Grant appropriate creator permissions
  • Consider team limits per user/project
  • Validate team names for uniqueness if needed

Permission Management

  • Use system permissions ($) for common operations
  • Create custom permissions for domain-specific actions
  • Leverage permission hierarchies for role-like behavior
  • Document permission requirements in API documentation

Team Switching

  • Update selected team when user explicitly switches
  • Include selected team in access token for easy access
  • Invalidate cached data when switching teams
  • Consider using team-scoped URL patterns (e.g., /teams/{teamId}/...)

Invitation Flows

  • Send verification codes via email
  • Include team information in invitation emails
  • Handle expired invitation codes gracefully
  • Allow re-sending invitations
  • Track invitation status and history

Data Isolation

  • Always filter by teamId for team-scoped data
  • Verify team membership before data access
  • Use database indexes on teamId columns
  • Consider row-level security in database
  • Test cross-team access carefully

Build docs developers (and LLMs) love