Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Security (OWASP Top 10:2025)  Module 13 – Enterprise SSO and provisioning (SAML 2.0 and SCIM 2.0)

Multi-tenant SSO and account linking

Route sign-ins by a domain a tenant has proved, identify people by tenant, issuer and subject, close every side door of required SSO, and keep tenants apart.

  • Intermediate
  • 40 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free intro, Pro: 8 of 40 minutes free
Free: An e-mail address is a hint, and the account is a tenant, an issuer and a subject. With a pass: the other 7 sections and 3 interview answers, about 32 minutes.

What you will learn

  • Route a login to a tenant's provider only after the tenant has proved it controls the domain, and answer unknown domains without revealing which tenants exist
  • Link accounts by issuer and subject inside one tenant, and never adopt an existing account because an e-mail address or username matches
  • Configure SSO enforcement so that password login, reset links, magic links and old invitations cannot bypass the provider, with a documented break-glass owner
  • Separate the trust of each connection, with its own metadata, keys, entity ID, ACS address, audience and client registration
  • Map identity provider groups to application roles through a table the tenant owns, and read the strength of a sign-in from the assertion when an action needs it

Before you start

What you’ll learn · Before you start

What you will learn

  • Route a login to a tenant's provider only after the tenant has proved it controls the domain, and answer unknown domains without revealing which tenants exist
  • Link accounts by issuer and subject inside one tenant, and never adopt an existing account because an e-mail address or username matches
  • Configure SSO enforcement so that password login, reset links, magic links and old invitations cannot bypass the provider, with a documented break-glass owner
  • Separate the trust of each connection, with its own metadata, keys, entity ID, ACS address, audience and client registration
  • Map identity provider groups to application roles through a table the tenant owns, and read the strength of a sign-in from the assertion when an action needs it

Before you start

On this page

A company’s identity provider can tell your application who signed in, and it cannot tell it which of your customers that person belongs to. In this lesson’s first program, matching sign-ins by e-mail address put two different people into the same account twice, split one person across two accounts and let a sign-in at one customer land in another customer’s account. An account keyed by tenant, issuer and subject did none of the three.

You will route logins by a domain a tenant has proved, find accounts the safe way, close every way in that single sign-in should have closed, keep each tenant’s trust apart, and turn groups into roles without letting a name decide.

An e-mail address is a hint, and the account is a tenant, an issuer and a subject

Multi-tenant SSO means that one application signs people in through the identity providers of many customer companies, each of them a tenant: One customer organisation of a shared application. Its users and data must stay apart from every other tenant's, even when they live in the same tables. More in “IDOR and object-level authorisation (BOLA)”. Account linking is the rule that decides which account a signed-in person gets, and it is where most multi-tenant SSO bugs live. Step through one sign-in and see where the tenant comes from at each stage:

Interactive · stage by stage One sign-in in an app with many companies Step through a sign-in and see where the tenant comes from at each stage, and what the app refuses to infer from an e-mail address.
One sign-in in an app with many companies, step 7 of 7. Showing Address typed, Domain looked up, One connection chosen, Redirect, request saved, Response on its own ACS, Tenant, issuer, subject (tenant, issuer, subject), Session in that tenant. In focus: Session in that tenant.Address typeddomain: after the last @Domain looked upa published value matchesOne connection chosenelse the plain sign-in formRedirect, request savedtied to this connectionResponse on its own ACSjudged by that record aloneTenant, issuer, subjecttenant, issuer, subjectSession in that tenantwith a new session ID

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Only a hint. The person types an address. It is a hint about where to send them, not who they are, because addresses are reassigned, unverified at some providers, and asserted by whatever provider a tenant connects.
  2. A domain someone proved. The app looks for a value that the tenant published under its domain and that matches the claim. Without a match, the person gets the ordinary sign-in form.
  3. One connection. A proved claim names one connection, which holds one identity provider's metadata, keys, entity ID and ACS address, and belongs to one tenant. From here the tenant is a fact about the connection and never something to read from a message.
  4. A request of our own. The app saves a request ID for this connection and redirects the browser to that provider, as in the first lesson of this module. Nothing about the tenant travels in a value that the browser can change.
  5. Response on its connection. The response arrives at the address of that connection, and it is compared with that connection's own record.
  6. Find the account in a tenant. The person is the pair of issuer and subject inside the tenant of the connection. A person who is not known yet gets a new account, and one whose address matches an existing account is held, never joined.
  7. A session that stays inside. The app starts a new session in that tenant. Every later request carries the tenant of the session, and none of them takes a tenant from the URL or the body without checking that the session belongs to it.

The tenant is a fact about the SSO connection: One tenant's link to one identity provider, with its own metadata, signing keys, entity ID and assertion consumer address. The tenant of a sign-in is a fact about the connection it arrived on. the response arrived on, and the person is the federated identifier: The pair of an issuer and a subject, taken inside one tenant, that names a person who signs in through an identity provider. An e-mail address can change or be reassigned, and this pair is meant not to. inside that tenant. An address says neither. The program makes eight sign-ins through three connections and finds the account in three ways:

Eight sign-ins, three ways to find the account Python · link/account_link.py
"""Eight sign-ins through three ways of finding the account of a person who signs in through a company's identity provider.

Each sign-in arrives on one connection, and each connection belongs to one tenant and one identity provider. The tenant
comes from the connection the response arrived on, never from the response. A person's account is wrong in three ways: it
is shared with someone else, it is made twice for one person, or it belongs to another tenant."""

CONNECTIONS = {  # the connection a response arrived on -> its tenant and the issuer set up for it
    "acme-main": ("acme", "https://login.acme.example/saml"),
    "acme-merger": ("acme", "https://sso.merger.example/saml"),
    "globex-main": ("globex", "https://idp.globex.example/saml"),
}
# (what happens, the connection it arrives on, the subject, the email address, the person who really signed in)
SIGN_INS = [
    ("Asha signs in for the first time", "acme-main", "s-1", "asha@acme.example", "Asha"),
    ("Asha signs in again", "acme-main", "s-1", "asha@acme.example", "Asha"),
    ("Dev, whose provider also uses s-1", "acme-merger", "s-1", "dev@merger.example", "Dev"),
    ("Ravi signs in at Globex", "globex-main", "g-7", "ravi@partner.example", "Ravi"),
    ("Ravi signs in at Acme, same address", "acme-main", "s-4", "ravi@partner.example", "Ravi"),
    ("Asha's address changed, her subject not", "acme-main", "s-1", "asha.k@acme.example", "Asha"),
    ("A newcomer holds Asha's old address", "acme-main", "s-9", "asha@acme.example", "Newcomer"),
    ("A newcomer holds Asha's new address", "acme-main", "s-12", "asha.k@acme.example", "Newcomer 2"),
]


class Directory:
    def __init__(self):
        self.accounts = {}  # id -> {"tenant": ..., "email": ...}
        self.links = {}  # a key -> account id

    def create(self, tenant, email):
        account = f"a{len(self.accounts) + 1}"
        self.accounts[account] = {"tenant": tenant, "email": email}
        return account


def by_email(directory, connection, subject, email):
    for account, data in directory.accounts.items():
        if data["email"].lower() == email.lower():
            return account  # an account with this address exists: join it
    return directory.create(CONNECTIONS[connection][0], email)


def by_subject(directory, connection, subject, email):
    if subject not in directory.links:
        directory.links[subject] = directory.create(CONNECTIONS[connection][0], email)
    return directory.links[subject]


def by_tenant_issuer_subject(directory, connection, subject, email):
    tenant, issuer = CONNECTIONS[connection]
    key = (tenant, issuer, subject)
    if key in directory.links:
        account = directory.links[key]
        directory.accounts[account]["email"] = email  # the address is an attribute that can change
        return account
    if any(a["tenant"] == tenant and a["email"].lower() == email.lower() for a in directory.accounts.values()):
        return "held"  # an address is never a reason to join accounts: a person confirms or an administrator decides
    directory.links[key] = directory.create(tenant, email)
    return directory.links[key]


LINKERS = [("email", by_email), ("subject", by_subject), ("tenant, issuer, subject", by_tenant_issuer_subject)]
results = {}
for name, linker in LINKERS:
    directory = Directory()
    results[name] = (directory, [linker(directory, connection, subject, email) for _, connection, subject, email, _ in SIGN_INS])

print((f"{'sign-in':<40}" + "".join(f"{name:<26}" for name, _ in LINKERS)).rstrip())
for i, (what, *_) in enumerate(SIGN_INS):
    print((f"{what:<40}" + "".join(f"{results[name][1][i]:<26}" for name, _ in LINKERS)).rstrip())

print("\nwhat went wrong")
for name, _ in LINKERS:
    directory, outcome = results[name]
    placed = [(connection, person, account) for (_, connection, _, _, person), account in zip(SIGN_INS, outcome) if account != "held"]
    shared = len([a for a in directory.accounts if len({p for _, p, acc in placed if acc == a}) > 1])
    split = len({(p, CONNECTIONS[c][0]) for c, p, _ in placed if len({acc for c2, p2, acc in placed if p2 == p and CONNECTIONS[c2][0] == CONNECTIONS[c][0]}) > 1})
    foreign = len([1 for c, _, acc in placed if directory.accounts[acc]["tenant"] != CONNECTIONS[c][0]])
    held = outcome.count("held")
    print(f"  {name:<26}shared by two people: {shared}, one person with two accounts in a tenant: {split}, in another tenant: {foreign}, held: {held}")

Output

sign-in                                 email                     subject                   tenant, issuer, subject
Asha signs in for the first time        a1                        a1                        a1
Asha signs in again                     a1                        a1                        a1
Dev, whose provider also uses s-1       a2                        a1                        a2
Ravi signs in at Globex                 a3                        a2                        a3
Ravi signs in at Acme, same address     a3                        a3                        a4
Asha's address changed, her subject not a4                        a1                        a1
A newcomer holds Asha's old address     a1                        a4                        a5
A newcomer holds Asha's new address     a4                        a5                        held

what went wrong
  email                     shared by two people: 2, one person with two accounts in a tenant: 1, in another tenant: 1, held: 0
  subject                   shared by two people: 1, one person with two accounts in a tenant: 0, in another tenant: 0, held: 0
  tenant, issuer, subject   shared by two people: 0, one person with two accounts in a tenant: 0, in another tenant: 0, held: 1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 account_link.py

Matching on the address put two different people into one account twice, once for a newcomer who now holds Asha’s old address and once for a newcomer who holds her new one. It also made a second account for Asha when her address changed, and it took Ravi’s sign-in at one customer into the account he has at another. Matching on the subject alone joined Dev, whose provider also uses s-1 for someone, to Asha’s account. Account linking by tenant, issuer and subject made no mistake, and it held the newcomer whose address belongs to an existing account, because an address is never a reason to join accounts. The mistake has shipped: when a provisioning request created a user, authentik linked it to any local account with the same user name (CVE-2026-72537).

Four more parts of the design follow from the same idea. Domain routing sends a login to a tenant’s provider only when the tenant has proved that it controls the domain of the address. SSO enforcement closes every other way in for a tenant that requires single sign-on. Per-connection trust gives each tenant its own keys, entity ID and ACS address, so a response made for one tenant is useless at another. Role mapping turns groups in a tenant’s directory into application roles through a table that the tenant owns.

Key idea

Identify a person by the tenant of the connection, the issuer and the subject, and treat the e-mail address as an attribute that can change and as a hint for routing, never as the account.

Checkpoint

Check: Finding the account of a person

  1. Question 1 of 2 Which key finds the account of a person who signs in through a company's identity provider?
    Show the answer: Question 1 of 2

    Answer: The tenant of the connection, the issuer and the subject

    The program's last column is this key. It made no account shared by two people, none split in a tenant and none in another tenant, and it held one newcomer whose address belonged to an existing account.

  2. Question 2 of 2 Where does the tenant of a sign-in come from?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: From the connection the response arrived on

    The tenant is a fact about the connection. Every check that follows compares the message with that connection's own settings.

Flashcards · 4 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What identifies an account in multi-tenant SSO, and what is the e-mail address?

    The tenant that owns the connection the response arrived on, plus the issuer and the subject inside that tenant. The address is an attribute that can change and a hint for routing, never the account. In the lesson, matching on the address put two different people into one account twice, split one person across two accounts and let a sign-in at one customer land in another's account; tenant, issuer and subject made none of these mistakes.

  2. Where does the tenant of a login come from?

    It is a fact about the connection the response arrived on, not about the address. Domain routing sends a login to a tenant's provider only when the tenant has proved that it controls the domain of the address.

  3. Which four parts of the multi-tenant SSO design follow from the same idea?

    Domain routing on a proved domain; SSO enforcement, which closes every other way in for a tenant that requires single sign-on; per-connection trust, with each tenant's own keys, entity ID and ACS address, so a response made for one tenant is useless at another; and role mapping, turning groups in a tenant's directory into application roles through a table that the tenant owns.

  4. Why is an address never a reason to join accounts?

    An address can change, as Asha's did, and can be reassigned, so a newcomer may hold her old one. The tenant, issuer and subject design held the newcomer whose address belonged to an existing account. Matching on the subject alone is no better, since another provider may use the same subject (s-1) for someone else. The mistake has shipped: authentik once linked a provisioned user to any local account with the same user name (CVE-2026-72537).

Route a login by a domain that the tenant has proved it owns Pro: opens with a Pro, Premium or Ultimate pass

In this part:

  • Enforce single sign-on so that no side door stays open
  • Keep each connection’s trust apart
  • Map groups to roles with a table the tenant owns, and ask for a fresh sign-in when it matters
  • Practise: routing, linking and enforcement for two tenants
    • Route a sign-in only to a domain that was proved
    • Find an account by tenant, issuer and subject
    • Close every way in except the identity provider
  • Interview questions
    • A multi-tenant app signs people in through each customer's identity provider and finds the account by e-mail address. A customer reports that one of their employees sees another company's data after signing in. What happened, how do you confirm it, and what is the fix?
    • A customer asks you to require single sign-on for everyone. List the ways someone can still get in after you hide the password form, say how you close each, and say how you stop the customer's administrators from locking themselves out.
    • After a release that maps identity provider groups to roles by name, a customer finds an intern with the admin role. How did it happen, what do you change, and how do people who move team or leave fit in?
  • Key takeaways

1 diagram · 4 runnable examples (JavaScript, Python) · 3 exercises with sample tests · 3 interview answers · about 42 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Terms in this lesson

break-glass account
A named owner account that keeps a password and a security key for the day the identity provider is down. Its use is rare, raises an alert and is tested on a schedule.
domain claim
A tenant's statement that it owns an e-mail domain, proved by publishing a value in that domain's DNS. The app routes sign-ins by a domain only while the published value matches the claim.
federated identifier
The pair of an issuer and a subject, taken inside one tenant, that names a person who signs in through an identity provider. An e-mail address can change or be reassigned, and this pair is meant not to.
role mapping
A table that turns an identity provider's group IDs into application roles, owned by the tenant's administrator. A group that is not in it grants nothing.
SSO connection
One tenant's link to one identity provider, with its own metadata, signing keys, entity ID and assertion consumer address. The tenant of a sign-in is a fact about the connection it arrived on.
SSO enforcement
A tenant setting that makes the identity provider the only way in, apart from named break-glass owners. Every other way to start or continue a session is closed.
step-up authentication
Asking for a stronger or more recent sign-in when an action needs one, instead of trusting an old session. SAML uses ForceAuthn for it, and OpenID Connect uses prompt=login or max_age.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.