Single Sign-On (SSO)
Modulo supports SSO via OpenID Connect (OIDC) and Security Assertion Markup Language (SAML) 2.0. When an employee signs in for the first time through an identity provider (IdP), Modulo creates their account and organization membership automatically via Just-In-Time (JIT) provisioning.
SSO requires a Team-tier license (MODULO_LICENSE_KEY). The Community tier does not include SSO. Configuration is admin-only at /settings/sso.
Prerequisites
Two environment variables control where SSO redirects land. Both must be correct before you register redirect URIs at your IdP.
MODULO_PUBLIC_URL
The externally reachable URL of your Modulo instance (for example, https://your-modulo.example.com). Modulo builds the OIDC callback URL from this value:
https://<host>/api/v1/auth/oidc/<provider-slug>/callback
The IdP must accept redirects to this exact URL. If MODULO_PUBLIC_URL is wrong, every login attempt will fail with a redirect error.
MODULO_FRONTEND_URL
Where the browser lands after login (the /auth/callback route). Defaults to MODULO_PUBLIC_URL. You only need to set this if the single-page application (SPA) is served from a different origin than the API. In the standard self-hosted setup (nginx serves the SPA and the API on the same origin), no extra setting is needed.
The provider slug
When you create an SSO provider in the Modulo UI, there is no “provider ID” or “slug” field. The slug is derived automatically from the provider name:
- Lowercase the name
- Replace every run of non-alphanumeric characters with a single hyphen
- Strip leading/trailing hyphens
- Truncate to 58 characters
- If the slug already exists, append
-2,-3, and so on (globally unique across all organizations)
Examples:
| Provider name | Slug |
|---|---|
Google |
google |
Azure AD |
azure-ad |
Okta (Production) |
okta-production |
My Long Provider Name Here 12345 |
my-long-provider-name-here-12345 |
This slug determines the redirect URI you must register at your IdP. The callback URL for a provider named Google is:
https://your-modulo.example.com/api/v1/auth/oidc/google/callback
A mismatch between the slug and the redirect URI registered at the IdP is the most common cause of the redirect_uri_mismatch error.
Google Workspace worked example
This walks through configuring Google as an OIDC provider, step by step.
Google Cloud Console setup
- Go to Google Cloud Console and create a new project, or select an existing one.
- Open the Google Auth Platform (
console.cloud.google.com/auth/overview). The wizard walks you through three steps:- Branding: set the app name and user-support email.
- Audience: choose Internal (Google Workspace organizations, no verification needed) or External (requires test users while in Testing status).
- Contact information and the user-data policy agreement. Click Create when done. You land on the Overview page.
- Go to Clients in the left nav (
console.cloud.google.com/auth/clients) and click Create client.- Application type: Web application.
- Under Authorized JavaScript origins, add your Modulo URL:
https://your-modulo.example.com. - Under Authorized redirect URIs, add the callback URL:
https://your-modulo.example.com/api/v1/auth/oidc/google/callback. - Click Create. Copy the Client ID and Client secret.
- (Alternative: the classic path APIs & Services > Credentials > Create Credentials > OAuth client ID works too and creates the same object.)
- Scopes (optional for basic sign-in): the
openid,email, andprofilescopes are non-sensitive and included by default. You can skip the Data Access step unless you need additional scopes. - Test users (External audience only): while the publishing status is Testing, add every user who needs to sign in under Audience > Test users. Google blocks sign-in for unlisted users when the app is in Testing status.
Modulo setup
- Log in to Modulo as an admin and go to
/settings/sso. - Click Add Provider and select OIDC.
- Fill in the fields:
- Name:
Google(this determines the sluggoogle) - Client ID: paste the Client ID from Google
- Client secret: paste the Client secret from Google
- Discovery URL:
https://accounts.google.com/.well-known/openid-configuration - Scopes:
openid email profile
- Name:
- Click Create.
- Click Test on the newly created provider. The test should report the discovered endpoints (authorization endpoint, token endpoint, userinfo endpoint).
- Ensure the provider is enabled (the toggle should be on).
Verify the flow
- Log out of Modulo.
- The login page should now show a Google button.
- Click the button, complete the Google login, and confirm you land back in the Modulo app.
Login experience
When a user visits the login page, Modulo fetches enabled SSO providers via GET /api/v1/auth/sso/providers. The response lists each enabled OIDC provider (by slug) and whether SAML is enabled. Each OIDC provider becomes a button linking to /api/v1/auth/oidc/<slug>/login. SAML providers show a button linking to /api/v1/auth/saml/<slug>/login.
SAML setup
SAML 2.0 follows the same provider-slug pattern as OIDC. Each SAML provider gets its own per-provider URLs for metadata, ACS (Assertion Consumer Service), and login initiation. This is what makes multi-org SSO work on a single Modulo instance. In SAML terms Modulo is the service provider (SP) and your identity system is the identity provider (IdP).
Per-provider URLs
When you create a SAML provider named Okta, the slug okta is derived automatically (same rules as OIDC). The three per-provider URLs are:
| Endpoint | URL | Purpose |
|---|---|---|
| SP metadata | GET /api/v1/auth/saml/okta/metadata |
Download this and give it to your IdP |
| ACS | POST /api/v1/auth/saml/okta/acs |
The IdP posts SAML responses here |
| Login | GET /api/v1/auth/saml/okta/login |
Redirect users here to start SAML login |
Each provider has its own Entity ID (the SP’s identity in the SAML exchange). The default Entity ID is:
https://your-modulo.example.com/api/v1/auth/saml/okta
You can override the Entity ID on the provider form if your IdP requires a specific value.
What to give your IdP
- Download the SP metadata from
GET /api/v1/auth/saml/<slug>/metadata. - Register the ACS URL (
POST .../<slug>/acs) and Entity ID at your IdP. - Upload the IdP’s metadata XML to Modulo (paste it into the provider form, or provide a metadata URL).
Audience restriction (required)
Your IdP MUST include an AudienceRestriction in the SAML assertion that matches the provider’s Entity ID. Modulo rejects SAML responses that do not carry an AudienceRestriction matching the expected Entity ID.
This is the most common cause of “works in my IdP tester, fails in Modulo” reports. Most IdP testers do not enforce AudienceRestriction, so a misconfigured assertion passes the tester but fails at Modulo. Check that your IdP’s audience value matches the Entity ID shown in the provider metadata.
Legacy single-tenant endpoints
The original singleton SAML endpoints still exist and work for one-org installs:
| Endpoint | URL |
|---|---|
| ACS | POST /api/v1/auth/saml/acs |
| Metadata | GET /api/v1/auth/saml/metadata |
| Login | GET /api/v1/auth/saml/login |
These resolve IdP configuration from environment variables (MODULO_SAML_IDP_METADATA_URL / MODULO_SAML_IDP_METADATA_XML) rather than the database. New multi-org setups should use the per-provider URLs described above.
Provisioning and roles
What happens at first sign-in depends on the provider’s auto_provision field and its allowed_domains list. Three modes:
auto_provision |
allowed_domains |
Who can join at sign-in |
|---|---|---|
false (default) |
any | Invitation only. A user must already hold an active membership, or hold a valid pending invitation for that email, which the sign-in consumes and grants the role it specifies. Anyone else is denied. |
true |
non-empty | Allowlisted domains. An authenticated user whose verified email domain is in the list is auto-provisioned. Everyone else falls back to the invitation path. |
true |
empty | Anyone who authenticates. Dangerous, and disabled unless the operator-level sso_unrestricted_provisioning feature flag is on. Saving the provider in this state without the flag is rejected with HTTP 422. |
Two rules apply to every mode:
- A pending invitation always wins. It is consumed first and grants the role the invitation specifies, not the provider default.
- An existing membership’s role is never changed. Provisioning never promotes or demotes a member who is already active.
When a membership is granted, the role comes from the provider’s default_role field if set, otherwise from the MODULO_SSO_DEFAULT_ROLE environment variable (default runner). Valid values are operator and runner.
A denied sign-in leaves no partial state: Modulo refuses before any account or membership write, so a rejected identity creates no orphan row.
Group to team mappings
You can map IdP groups to Modulo teams. When a user authenticates, their group memberships are checked against the mappings. If a match is found, the user is added to (or their role is updated in) the corresponding Modulo team.
Each mapping has three fields:
| Field | Description |
|---|---|
idp_group |
The group name or claim value from the IdP |
team_id |
The UUID of the Modulo team |
team_role |
The role to assign within the team (default: viewer) |
For this to work, the IdP must emit a groups claim in the OIDC ID token. For SAML providers, the claim can be groups, memberOf, or Group.
Google limitation: Google’s standard OIDC ID token does not include a groups claim. To use group mappings with Google, you need extra claim provisioning at the IdP (for example, through a custom claim in Google Workspace or an intermediate service). Group mapping does not work out of the box with plain Google OIDC.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Redirect lands on the wrong host | MODULO_FRONTEND_URL or MODULO_PUBLIC_URL is incorrect |
Set both variables to the correct externally reachable URL |
redirect_uri_mismatch at the IdP |
The redirect URI registered at the IdP does not match the slug-based callback URL | Check the provider name in Modulo, derive the slug, and update the redirect URI at the IdP to match exactly |
| “Invalid state parameter” error | Expired or blocked cookie, or a stale login tab | Clear cookies, close stale tabs, and try again |
| No SSO button on the login page, or 402 error | SSO feature not enabled (Community tier or missing license) | Set MODULO_LICENSE_KEY to a Team-tier license |
| Connection test fails | Discovery URL unreachable from the Modulo server | Verify network access from the server to the IdP’s discovery endpoint |
| Users are provisioned but not added to teams | Group mappings not configured, or the IdP does not emit a groups claim |
Configure group mappings and verify the IdP sends group information |
| Sign-in is denied with HTTP 401 and no account created | auto_provision is off, or the user’s email domain is not in allowed_domains, and no invitation exists |
Either enable auto_provision with the user’s domain in allowed_domains, or send them an invitation |
Provider saved with empty allowed_domains is rejected with HTTP 422 |
auto_provision=true with no allowlist is the unrestricted mode |
Add the allowed domains, or enable the sso_unrestricted_provisioning feature flag deliberately |
| SAML login works in IdP tester but fails in Modulo | Missing or mismatched AudienceRestriction | Check that the IdP sends an AudienceRestriction matching the provider’s Entity ID (visible in the SP metadata) |
| SAML response rejected with “audience” error | Entity ID mismatch between IdP and Modulo | The IdP’s audience value must exactly match the Entity ID in the provider metadata, including the full URL |
| IdP-initiated SSO fails silently | IdP sends response to wrong ACS URL | Register the per-provider ACS URL (/api/v1/auth/saml/<slug>/acs), not the legacy /saml/acs |
Related
- SCIM Provisioning: automated user and group lifecycle management
- Configuration: environment variables including
MODULO_LICENSE_KEY - Secrets: credential encryption and the client secret at rest
- Troubleshooting: fixes for common setup issues