In SaaS applications, authentication and authorization are critical. As your platform grows to serve multiple customers, each tenant wants to use their own identity provider (IDP), users need automatic provisioning from corporate directories, and access control must work across tenants.
This guide covers OAuth 2.0/OIDC fundamentals, multi-tenant authentication patterns, SCIM-based directory synchronization, and practical implementation details.
Understanding OAuth 2.0 and OIDC
What is OAuth 2.0?
OAuth 2.0 is an authorization framework that enables applications to access resources on behalf of users without sharing passwords. Think of it as a valet key for your digital resources - you give limited access without exposing your master credentials.
Key Concepts:
- Resource Owner: The user who owns the data
- Client (Application): The app requesting access
- Authorization Server: Issues tokens after authentication (e.g., Google’s OAuth service)
- Resource Server: Hosts the protected resources (e.g., Google Photos API)
The Authorization Code Flow:
1. User clicks "Sign in with Google"
2. App redirects to authorization endpoint with client_id, redirect_uri, scope, state
3. User authenticates and grants consent
4. Authorization server redirects back with authorization code
5. App exchanges code for tokens (access token, refresh token, ID token)
6. App uses access token to call APIs
What is OIDC (OpenID Connect)?
OIDC extends OAuth 2.0 to standardize identity verification. While OAuth 2.0 answers “what permissions do you have?”, OIDC also answers “who are you?”
OIDC adds:
- ID Token: JWT containing user identity (email, name, etc.)
- UserInfo Endpoint: Standardized endpoint for user profile data
- Standardized Scopes:
openid,profile,email,groups
Bearer Tokens
OAuth 2.0 uses bearer tokens - whoever carries the token can use it. Protect tokens by using HTTPS, storing securely (httpOnly cookies preferred), implementing expiration/rotation, and never logging them.
Multi-Tenant Authentication Architecture
The Challenge
Building direct integrations with every IDP (Keycloak, Azure AD, Okta, Google Workspace) is time-consuming, maintenance-heavy, and complex due to protocol variations (SAML, OIDC, OAuth 2.0).
Each tenant wants:
- Their own identity provider
- Seamless user authentication
- Complete isolation between tenants
IDP Federation Service
An IDP Federation Service (like Ory Polis or Auth0) acts as a bridge between customer IDPs and your platform.
When you need federation:
- Enterprise customers requiring their own IDP (Azure AD, Okta, custom SAML)
- Multiple tenants with different authentication providers
- Support for SAML, OIDC, and other protocols
- Automated user provisioning via SCIM
When you might not need federation:
- Single IDP for all users (e.g., only Google OAuth)
- Simple B2C application with email/password auth
- All tenants share the same authentication provider
Architecture:
┌─────────────────────────────────────────────────────────────┐
│ User Browser │
└────────────────────────┬────────────────────────────────────┘
│ 1. GET /login
▼
┌─────────────────────────────────────────────────────────────┐
│ Web Application │
│ - Identifies tenant │
│ - Redirects to IDP Federation Service │
└────────────────────────┬────────────────────────────────────┘
│ 2. OAuth authorize request
▼
┌─────────────────────────────────────────────────────────────┐
│ IDP Federation Service │
│ - Routes to customer's IDP (tenant-based) │
│ - Issues federation tokens after authentication │
└────────────────────────┬────────────────────────────────────┘
│ 3. User authenticates
▼
┌─────────────────────────────────────────────────────────────┐
│ Customer IDP (Keycloak, Azure AD, etc.) │
└─────────────────────────────────────────────────────────────┘
Key Benefits:
- Single OAuth Interface: Backend integrates with one service, not multiple IDPs
- Multi-Tenant Support: Each customer uses their own IDP
- Protocol Abstraction: Federation service handles SAML, OIDC, OAuth 2.0 complexities
- Consistent Token Format: Uniform token structure regardless of upstream IDP
Tenant Identification
Tenants can be identified through various mechanisms depending on your application architecture:
- Email domain-based: Extract tenant from user’s email domain
- Subdomain-based: Use subdomain as tenant identifier (e.g.,
acme.yourapp.com) - Path-based: Include tenant in URL path (e.g.,
/acme/login) - Header-based: Custom HTTP header containing tenant ID
- Database lookup: API endpoint to resolve tenant from email or other identifier
Choose the approach that best fits your application’s routing and user experience requirements.
Authentication Flow
Step 1: Tenant Identification
Application identifies the tenant through one of the methods above, then generates a CSRF state parameter for security.
Step 2: OAuth Authorization
GET /federation/api/oauth/authorize?
tenant=<tenant-id>&
product=yourapp&
redirect_uri=https://yourapp.com/callback&
state=<random-csrf-token>&
response_type=code&
scope=openid profile email groups
Step 3: Token Exchange
// App exchanges authorization code for tokens
POST /federation/api/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=<auth-code>&
client_id=<id>&
client_secret=<secret>&
redirect_uri=<callback>
// Response includes: access_token, id_token, refresh_token
Step 4: Token Validation
// Backend validates token via userinfo endpoint
GET /federation/api/oauth/userinfo
Authorization: Bearer <access_token>
// Response:
{
"sub": "user-123",
"email": "alice@acme-corp.com",
"groups": ["developers", "admin"]
}
Authorization and Access Control
Authorization can be implemented using various models depending on your application’s complexity and requirements:
- Role-Based Access Control (RBAC): Users have roles (admin, editor, viewer) with predefined permissions
- Attribute-Based Access Control (ABAC): Access decisions based on user attributes, resource attributes, and environmental conditions
- Group-Based Access Control: Users belong to groups that determine access (common in enterprise environments)
- Resource-Based Access Control: Direct user-to-resource permissions (suitable for simpler applications)
This guide focuses on group-based access control since it integrates naturally with enterprise IDPs and directory services, making it practical for multi-tenant SaaS applications.
Group-Based Access Control
How It Works:
- Users belong to groups (from IDP token claims)
- Resources are restricted to specific groups
- Users access resources only if their groups match
Example:
User "alice" → groups: ["developers", "admin"]
Resource "github-tools" → allowed groups: ["developers"]
Result: alice can access
User "bob" → groups: ["viewers"]
Resource "github-tools" → allowed groups: ["developers"]
Result: bob cannot access
Implementation:
func canAccess(userGroups, allowedGroups []string) bool {
if len(allowedGroups) == 0 {
return true // Public resource
}
for _, userGroup := range userGroups {
for _, allowed := range allowedGroups {
if userGroup == allowed {
return true
}
}
}
return false
}
Tenant Isolation
Critical: Users from Tenant A must never access Tenant B’s resources.
Approach:
- Extract tenant identifier during authentication
- Filter all database queries by
tenant_id - Propagate tenant ID via application context
// Middleware adds tenant to context
func TenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := auth.ClaimsFromContext(r.Context())
tenantID := extractTenant(claims)
ctx := auth.WithTenant(r.Context(), tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// All queries filter by tenant
func GetServers(ctx context.Context) ([]Server, error) {
tenantID := auth.TenantFromContext(ctx)
return db.Query("SELECT * FROM servers WHERE tenant_id = $1", tenantID)
}
IDP Group Support Challenges
Not all IDPs support group claims in OIDC tokens. Google Workspace and GitHub don’t provide group claims. Azure AD returns group object IDs (GUIDs), not names. Keycloak, Okta, and most enterprise IDPs support groups fully.
A hybrid authorization model addresses these variations:
Database-Backed Groups (default): For IDPs without group support (Google, GitHub)
- Manage groups in your database
- Assign users to groups manually or via SCIM
IDP Passthrough (enterprise): For IDPs with full group support (Keycloak, Azure AD, Okta)
- Groups come directly from IDP token claims
- Zero manual management
func ResolveUserGroups(ctx context.Context, claims *Claims) ([]string, error) {
tenant := getTenant(ctx)
if tenant.GroupSource == "idp" {
return claims.Groups, nil // Use IDP groups
}
return db.GetUserGroups(ctx, claims.Subject) // Use DB groups
}
SCIM and Directory Sync
SCIM (System for Cross-domain Identity Management) is a standardized protocol for automating user provisioning and deprovisioning between systems. It enables automated provisioning (new employees get access automatically), automated deprovisioning (departing employees lose access immediately), group sync (organizational changes propagate automatically), and compliance (access control stays in sync with HR systems).
SCIM 2.0 Resources
User Resource:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "user-123",
"userName": "alice@acme-corp.com",
"name": {"givenName": "Alice", "familyName": "Smith"},
"emails": [{"value": "alice@acme-corp.com", "primary": true}],
"active": true
}
Group Resource:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"id": "group-456",
"displayName": "Developers",
"members": [{"value": "user-123", "display": "alice@acme-corp.com"}]
}
Directory Sync Architecture
┌─────────────┐ SCIM 2.0 ┌────────────────┐
│ IDP │ ────────────> │ SCIM Server │
│ (Azure AD, │ POST /Users │ (Federation │
│ Okta) │ POST /Groups │ Service) │
└─────────────┘ └────────┬───────┘
│
│ Polls every 5 min
│ GET /api/v1/dsync/*
▼
┌─────────────────┐
│ Sync Worker │
│ (Background) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Database │
│ users, groups, │
│ user_groups │
└─────────────────┘
Components:
- SCIM Server: Receives SCIM requests from IDPs, stores users/groups
- Sync Worker: Polls SCIM server periodically, syncs to your database
- Database: Stores synced users, groups, memberships
IDP Support: As discussed in the Authorization section, not all IDPs support group claims equally. For SCIM directory sync: Azure AD, Okta, and Keycloak provide full support (users + groups). Google Workspace supports SCIM users reliably but has limited group provisioning (use Google Directory API for groups). GitHub doesn’t support SCIM (use OIDC tokens + custom sync).
SCIM Endpoint Setup
Create Directory Sync Configuration:
curl -X POST "https://federation.example.com/api/v1/dsync" \
-H "Authorization: Api-Key <key>" \
-H "Content-Type: application/json" \
-d '{
"tenant": "acme",
"product": "yourapp",
"type": "generic-scim-v2",
"name": "Acme Corp Directory"
}'
Response includes SCIM endpoint and secret:
{
"scim": {
"endpoint": "https://federation.example.com/api/scim/v2.0/<id>",
"secret": "scim-token-xyz"
}
}
Configure in IDP:
- Azure AD: Enterprise Application → Provisioning → SCIM endpoint
- Okta: Application → Provisioning → SCIM 2.0
Sync Worker Implementation
// Sync worker runs periodically (e.g., every 5 minutes)
func (s *SyncService) Start(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, tenant := range s.getTenantsWithSync(ctx) {
s.syncUsers(ctx, tenant)
s.syncGroups(ctx, tenant)
s.syncMemberships(ctx, tenant)
}
}
}
}
Key Requirements: The sync worker must handle pagination for large directories, implement upsert logic to create or update users and groups, ensure multi-tenant isolation by syncing each tenant independently, and include robust error handling that logs errors and continues syncing other tenants.
Token Validation
Backend services validate tokens via the userinfo endpoint:
func validateToken(ctx context.Context, token string) (*Claims, error) {
req, _ := http.NewRequestWithContext(ctx, "GET",
issuerURL + "/api/oauth/userinfo", nil)
req.Header.Set("Authorization", "Bearer " + token)
resp, _ := client.Do(req)
defer resp.Body.Close()
var userinfo UserInfo
json.NewDecoder(resp.Body).Decode(&userinfo)
return &Claims{
Subject: userinfo.Sub,
Email: userinfo.Email,
Groups: userinfo.Groups,
}, nil
}
Token validation doesn’t require client credentials. The federation service validates the token and returns user information, following the standard OAuth 2.0 pattern for resource servers.
Audience Validation for Multi-Tenant
In multi-tenant setups, each tenant has a different client_id, making static audience validation impractical. Instead, configure the authentication to skip strict audience validation:
auth:
provider: "federation-service"
issuer: "https://federation.example.com"
strict_audience_validation: false # Multi-tenant mode
jwks_path: "/oauth/jwks"
What gets validated:
- Token signature (via JWKS)
- Issuer (must match trusted federation service)
- Expiration (token must be valid)
- Audience (skipped for multi-tenant)
Security: Signature + Issuer validation is sufficient when using a trusted IDP federation service. The federation service manages client registration, preventing unauthorized access.
CSRF Protection
The state parameter prevents cross-site request forgery attacks by ensuring the OAuth callback came from your application:
// Generate and store state parameter
state := uuid.New().String()
session.Set("oauth_state", state)
// Include in authorization URL
authURL := fmt.Sprintf("%s?state=%s&...", federationURL, state)
// Validate in callback
savedState := session.Get("oauth_state")
if savedState != receivedState {
return errors.New("CSRF attack detected")
}
Data Models
Store synced users, groups, and memberships in your database with proper tenant isolation:
User Model:
type User struct {
ID string `db:"id"`
TenantID string `db:"tenant_id"`
ExternalID string `db:"external_id"` // SCIM ID
Email string `db:"email"`
Active bool `db:"active"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Group Model:
type Group struct {
ID string `db:"id"`
TenantID string `db:"tenant_id"`
ExternalID string `db:"external_id"` // SCIM ID
DisplayName string `db:"display_name"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
User-Group Membership:
type UserGroup struct {
UserID string `db:"user_id"`
GroupID string `db:"group_id"`
}
Conclusion
Building multi-tenant authentication requires balancing flexibility with security. Federation services simplify IDP integrations, group-based authorization works well with enterprise directories, and SCIM automates user provisioning. Not all IDPs support group claims equally, so a hybrid approach (database-backed groups for some, IDP passthrough for others) provides the best coverage.
Security isn’t a one-time thing - it needs ongoing attention, monitoring, and updates as threats evolve. The patterns here provide a solid foundation, but adapt them to your specific requirements and security posture.
Resources
- RFC 6749 - OAuth 2.0 Authorization Framework
- OpenID Connect Core 1.0
- RFC 7644 - SCIM Protocol
- Ory Polis Documentation
This guide is intended for educational purposes. Always consult official specifications (RFCs) and security best practices for production implementations.