Skip to content

SSO / SAML

Direction masks is See
Sign in to masks with Google, Okta, Entra, or a SAML identity provider the service provider Providers
Sign in to a SAML-only app, such as a wiki, with masks the identity provider SAML applications
Keep masks’ people in sync with a directory the SCIM service Provisioning

A provider is another service people sign in with, over OpenID Connect, OAuth 2.0, or SAML 2.0. masks links the account there to an account in masks. That link is a connected account.

A provider stands in for the password. A person’s sign-in policy still asks for a second factor when it requires one.

Role Creates accounts Updates the profile
credential No. It signs in only the account it is connected to. No
delegate Yes, for a new person. Name, photo, and address, on every sign-in

Use delegate when the company directory decides who works there. A credential provider that confirms an existing account’s address connects to that account only after it signs in another way.

masks tells apps email_verified: true, so it trusts a provider’s address only when:

  • the address is in one of the provider’s email domains, or
  • the provider has trusts_email on and reports the address as confirmed.

Turn on trusts_email only for a provider that confirms addresses. The Google, Apple, GitHub, GitLab, Slack, LinkedIn, and Discord presets turn it on.

  • An address the provider reports as unconfirmed stays unconfirmed, even in a listed domain.
  • SAML has no confirmation field, so a SAML provider is trusted only for its listed domains.
  • For an untrusted address, a delegate provider creates no account and no invitation is accepted.
  • A person with no address at all still gets an account, with no address.

The client’s sign-in policy decides:

  • first_factors must include provider.
  • providers lists which ones. Unset means all.

A signed-in person can connect any offered provider from their account page, after signing in within the last fifteen minutes. masks refuses an account there that is already connected to someone else.

Someone who types an address at a company’s domain can go straight to that company’s provider, with no password prompt. masks does this only for a domain the tenant has proven it controls.

  1. In /manage, open Settings, then Domains, enter the domain, and choose the provider.
  2. Publish the TXT record masks shows, such as _masks-challenge.acme.example with the value masks-verification=….
  3. Choose Check now, or wait for the hourly check.

Once the domain is proven, an address there starts the provider’s sign-in, even when the provider belongs to an organization the app did not name. An address at any other domain, and a nickname, sign in the usual way.

  • A domain can be proven by one tenant at a time. Another tenant’s claim to it is refused.
  • A proven record that stops answering is released after seven days, and people sign in the usual way again.
  • A sign-in policy without the provider factor sends nobody to a provider.

A preset fills in every setting except the credentials.

Preset Protocol Asks for
Google OpenID Connect
Microsoft OpenID Connect directory (tenant) ID
Apple OpenID Connect team ID, key ID, private key
GitHub OAuth 2.0
GitLab OpenID Connect domain, gitlab.com by default
Slack OpenID Connect
LinkedIn OpenID Connect
Discord OAuth 2.0
Facebook OAuth 2.0
X OAuth 2.0
Okta, Auth0 OpenID Connect domain
Keycloak OpenID Connect domain, realm
Microsoft Entra, Okta, Google Workspace SAML 2.0 metadata
Notion MCP nothing; masks registers itself
OpenID Connect, OAuth 2.0, SAML 2.0, MCP server everything

An MCP provider signs nobody in. Apps use it through a delegation.

masks checks the id_token’s signature against the issuer’s keys, and its issuer, audience, and nonce against the request. The request uses PKCE. The subject is sub, or oid for a Microsoft directory.

With no id_token, masks reads the provider’s userinfo endpoint. subject_claim and claims map each claim to a path in the response, such as data.id for X or id for GitHub. The request uses PKCE, and masks discards the provider’s tokens once it has the profile.

An emails URL lists confirmed addresses separately from the profile, as GitHub does. masks uses the primary confirmed address from it, and ignores an address that appears only in the public profile.

Give the identity provider two values from the provider’s page in /manage:

Assertion consumer service URL https://<tenant>/login/provider/<key>/callback
Service provider entity ID https://<tenant>/login/provider/<key>/metadata, which also serves masks’ metadata

Then give masks the identity provider’s metadata, as a URL or pasted in. A URL is fetched again nightly, so a rotated certificate is picked up.

masks trusts an assertion only when it:

  • is signed by a certificate in the identity provider’s metadata,
  • answers the request this browser sent (InResponseTo),
  • names this service provider as audience and recipient,
  • names the identity provider’s entity ID as issuer,
  • is within its validity window, with one minute of clock drift.

masks refuses unsolicited, identity-provider-initiated responses, and marks each request used, so a response cannot be replayed. Ask for a persistent name ID. A transient one changes on every sign-in, so each sign-in looks like a new person.

Apple’s client secret is a short-lived ES256 token that masks signs with the provider’s .p8 private key, so the provider stores a team ID, a key ID, and that key. Apple sends the person’s name only on their first sign-in, and masks keeps it.

Apple and SAML post back across sites, and browsers do not send a SameSite=Lax cookie with a cross-site POST. masks holds the posted parameters for five minutes under a one-time handle and redirects to the same URL as a GET, which sends the cookie.

masks is the identity provider for apps that sign people in only over SAML 2.0. The app sends an AuthnRequest, masks signs the person in under their sign-in policy, and masks posts a signed assertion back.

Metadata https://demo.masks.example/saml/metadata
Entity ID The same URL.
Single sign-on https://demo.masks.example/saml/sso, HTTP-Redirect or HTTP-POST.
Certificate In the metadata, one per tenant signing key.

Most apps need only the metadata URL. After a key rotation, the app must read it again. The retiring key’s certificate stays in the metadata during the overlap.

  1. In /manage, open Clients → Add client → SAML app.
  2. Paste the app’s metadata.
  3. Click Read it. masks fills in its entity ID, HTTP-POST assertion consumer services, and request-signing certificate.

A SAML app is an approved client with no grants and no secret. It cannot use /authorize or /token.

Setting
Entity ID The Issuer its AuthnRequest names.
Assertion consumer services The only URLs masks posts to.
Names people by A persistent identifier (pairwise if the client is), or their email.
Refuse unsigned requests Check every request against the app’s certificate.
Let people start from masks Answer /saml/initiate/<client_id> without a request.

masks shows an error and posts nothing when a request:

  • names an Issuer with no registered app,
  • has an AssertionConsumerServiceURL not registered, or a binding other than HTTP-POST,
  • asks for a NameID format masks does not issue,
  • is more than eight minutes old, more than three minutes in the future, or has another Destination,
  • was already received, or repeats an ID,
  • fails a required signature check (SHA-256 or stronger), or carries two signatures,
  • repeats a parameter, or is larger than 64 KB,
  • declares a document type, which is how XML external entities get in.
Request or outcome Response
ForceAuthn The person signs in again.
IsPassive NoPassive, with no page shown.
Suspended, declined, or stopped by the policy RequestDenied, in place of an assertion.

The response and the assertion are both signed with RSA-SHA256. The assertion is addressed to the app’s entity ID, confirmed for its assertion consumer service and the request ID, valid for five minutes, names the session in SessionIndex, and names https://refeds.org/profile/mfa when a second factor was used.

Attributes use the claim names email, name, given_name, family_name, and preferred_username. samlAttributes maps them to an app’s own names. masks releases only what the person’s openid profile email consent covers, and an email only once it is confirmed, as attribute or NameID. The posting page’s content security policy allows only its own script.

Single logout, encrypted assertions, the artifact binding, and choosing an assertion consumer service by index are not supported.

A directory such as Entra ID, Okta, Google Workspace, or JumpCloud keeps masks’ people in sync over SCIM 2.0 (RFC 7643, RFC 7644). It adds, renames, and suspends people in masks as it does in its own directory.

Each tenant’s base URL is its issuer followed by /scim/v2:

https://demo.masks.example/scim/v2
  1. In /manage, open Settings → Provisioning and issue a token.
  2. Label it after the directory.
  3. Choose a lifetime, up to one year.
  4. Copy it. masks shows it once.
  5. In the directory, enter the base URL and the token as a bearer token.

The token list shows each token’s last use. A revoked token is refused.

A client can also provision. It must be approved and allowed masks:scim, request its own token with client_credentials, and name the base URL as its resource.

/Users supports reads, filters, creation, PUT, PATCH, and deletion.

SCIM masks
id The actor’s uuid.
userName The nickname, or the email when it is an address.
externalId Stored and filterable. It is unique in the tenant, or in the organization for a directory for one organization.
name, displayName The name claims.
emails The primary email, treated as confirmed.
phoneNumbers, photos, profileUrl, locale, timezone The matching claims.
active Whether the person is suspended.
password Set, if the directory sends one.

A provisioned person holds the standard scopes and has no password unless one was sent. They sign in through a provider that trusts the same email, or by accepting an invitation.

Filters
Operators eq, ne, co, sw, ew, pr, gt, ge, lt, le
Joins Up to four comparisons with and.
Attributes userName, externalId, emails.value, displayName, name.givenName, name.familyName, active, the meta dates
eq only userName, active
Case externalId compares exactly. The other attributes ignore case.

Every response has a weak ETag, and an If-Match naming an older version is refused. /ServiceProviderConfig, /ResourceTypes, and /Schemas describe the rest. /Groups answers only a directory for one organization. Bulk operations and sorting are not supported.

active: false, or Suspend in /manage, suspends a person. Every session ends and every token stops working, refresh tokens included. Sign-in is refused after the first factor, so entering a name alone does not reveal the suspension. active: true restores them. DELETE removes them.

A provisioning token changes many accounts, so it cannot:

  • set the password or email of anyone holding a masks: scope,
  • suspend or delete the last active holder of masks:manage,
  • change anyone’s scopes.

Every change is recorded in the person’s activity, marked as SCIM.

A provisioning token can belong to one organization, so a customer’s directory manages that customer’s people and nobody else. A token for every account, and a client that may carry masks:scim, can come only from an owner, since either reaches managers too.

Request What happens
List or read users Only the organization’s members. Anyone else is not found.
Create a user A new account that joins as member. Its email must be at a domain proven for one of the organization’s providers, and is then treated as confirmed. Any other address is refused with 400 before masks looks for an account holding it. A user without an email is allowed.
Replace or patch a user Allowed only for an account this directory created that belongs to no other organization and manages nothing. Anyone else is refused with 403, so an owner who adds an existing account cannot then take it over through the directory.
Delete a user Removes them from the organization and revokes its tokens. The account stays.

The organization’s directory keeps its own externalId for each member, so two organizations’ directories can use the same value. A userName, email, or externalId that is already taken is refused with 409 and the same uniqueness detail every time, so the answer does not say who holds it or where. An actor’s page in /manage shows the externalId each organization’s directory holds for them.

A directory for one organization manages roles through /Groups. Each of the organization’s roles is one group. Its displayName is the role, its id is the organization’s uuid and the role joined by a dot, and its members are the people who have accepted that role.

Request What happens
List or read groups Every role, owner and member included. Filter with displayName eq or id eq. excludedAttributes=members leaves members out.
Add a member Gives them that role in place of the one they held.
Remove a member Moves them back to member. Removing from member changes nothing. They stay in the organization.
Replace members with PUT or a replace operation Adds everyone named and moves everyone else back to member. People this directory did not create keep their role. For member, it only adds.
Create a group Adds a role with that name. An existing role is a conflict.
Delete a group Removes the role once nobody holds it. owner and member cannot be deleted.
Rename a group Refused.

A group takes only people this directory created who belong to no other organization and manage nothing. Naming anyone else is refused with 400, and the answer is the same whether or not the account exists. Removing the last owner is refused with 409. A role change revokes the person’s access tokens for the organization and is recorded in its activity, marked as SCIM.

A token for every account lists no groups and cannot change them.