One OAuth App, Every Customer: Scaling B2B Sign-In With Shared Applications
If you're building a B2B SaaS application, one question inevitably comes up:
How do my customers' users sign in to my application?
Your own team probably already signs in using an OAuth application registered in your O2ID tenant. As new customers onboard, it might seem natural to create another OAuth application for each customer organization.
That works for a handful of customers. It doesn't scale to hundreds or thousands.
Instead, O2ID lets you share a single OAuth application with customer Organizations, allowing each organization's own users to authenticate using their own credentials while O2ID still issues tokens scoped entirely to that organization.
In this tutorial you'll build exactly that.
What you'll build
Imagine Acme provides a SaaS product called Acme Console.
Acme manages its identity in an O2ID tenant with the slug acme.
One of Acme's customers is Globex Corp, which has been onboarded as an Organization inside Acme's tenant.
By the end of this tutorial:
- Acme will have a single OAuth application named Acme Console
- Globex will be allowed to use that application
- Globex users will authenticate with their own accounts
- O2ID will issue access tokens scoped to Globex, even though the application belongs to Acme
In other words, Globex can use Acme's application without registering another OAuth client.
Before you begin
This tutorial assumes:
- O2ID is running locally on
http://localhost:8080 - You're already authenticated with
o2idctl jqis installed- You'll use oidcdebugger.com to complete the OAuth flow without writing any application code
"Acme" below is a stand-in for your own tenant — the one o2idctl is
already authenticated against, not a tenant you need to create. Every
/t/acme/... URL in this post should use your own tenant's slug instead.
If you haven't created one of your own, that's system — the tenant O2ID
bootstraps on first run. Set it once so every command below can just use
it:
TENANT=system # replace with your own tenant's slug, if you have one
Step 1: Create the application you'll share
First, create the OAuth application that both Acme employees and customer organizations will eventually use.
APP_JSON=$(o2idctl applications create \
--name "Acme Console" \
--callback-url https://oidcdebugger.com/debug \
--scope users:read)
APP_ID=$(printf '%s' "$APP_JSON" | jq -r .id)
CLIENT_ID=$(printf '%s' "$APP_JSON" | jq -r .clientId)
We'll use:
APP_IDto share the application later.CLIENT_IDwhen starting the OAuth authorization flow.
Notice that we didn't provide a client secret. This application is configured as a public client, so authentication relies on PKCE instead.
The callback URL points to oidcdebugger.com, which lets us exercise the OAuth flow entirely from the browser. In a real deployment, this would be your application's own callback endpoint.
Step 2: Create a customer Organization
Next, onboard one of Acme's customers.
o2idctl organizations create --slug globex --name "Globex Corp"
A freshly created Organization has no users of its own, so create Globex's first user directly, using your own Acme credentials:
o2idctl organizations switch globex
USER_JSON=$(o2idctl users create --email admin@globex.example --password correct-horse-battery-staple)
USER_ID=$(printf '%s' "$USER_JSON" | jq -r .id)
ROLE_JSON=$(o2idctl roles create --name "Globex Admin" --scope users:read)
ROLE_ID=$(printf '%s' "$ROLE_JSON" | jq -r .id)
o2idctl users assign --user "$USER_ID" --role "$ROLE_ID"
o2idctl organizations switch
organizations switch only changes which path these commands address — it
keeps using your existing Acme credentials throughout. Running these
commands against Globex's own path with those credentials works because
your Acme admin role holds organizations:manage, which authorizes every
request at every descendant Organization (see Organizations — Delegated
administration). No
separate login, application, or credentials of your own are needed to
create them.
The role matters, not just the user: a signed-in user with no role has no
scopes at all, so users:read — the scope Acme Console asks for — would
get silently dropped from consent and every token issued to them, no
matter what's requested. See Managing Roles for
more on how roles and scopes work.
At this point, Globex has a user (with a role) but no OAuth application of its own.
Step 3: Share the application
Now allow Globex to use Acme Console.
o2idctl applications share "$APP_ID" --policy selective --organizations globex
That's all that's required. --policy selective shares with exactly the
Organizations named by --organizations (a comma-separated list, for more
than one); --policy all (no --organizations needed) shares with every
current and future Organization at once instead.
Sharing doesn't create another OAuth client. Instead, it grants Globex permission to use the existing application through Globex's own OAuth endpoints.
You can verify the current shares using:
o2idctl applications get "$APP_ID"
which includes the application's current sharing state — its policy, and which Organizations it's shared with — directly in the response.
Step 4: Sign in as a Globex user
Now let's prove that the setup works.
Print the Authorize URI you'll need, with $TENANT already substituted:
echo "http://localhost:8080/t/$TENANT/org/globex/oauth2/authorize"
Open https://oidcdebugger.com and configure the following values.
| Field | Value |
|---|---|
| Authorize URI | the URL printed above |
| Client ID | $CLIENT_ID |
| Redirect URI | https://oidcdebugger.com/debug |
| Scope | openid users:read |
| Response Type | code |
| Use PKCE | ✓ Enabled |
Click Send Request.
Because you're not signed in yet, O2ID redirects you to Globex's login page.
Sign in using the administrator created earlier:
Email: admin@globex.example
Password: correct-horse-battery-staple
After authentication, you're redirected back to oidcdebugger with an authorization code.
Now enter the Token URI — print it the same way:
echo "http://localhost:8080/t/$TENANT/org/globex/oauth2/token"
Exchange the authorization code.
You should receive a response similar to:
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid users:read",
"id_token": "..."
}
Congratulations!
You've just signed into Acme Console as a Globex user, using an OAuth application that Globex never had to register.
Step 5: Verify the issued token
Let's confirm that the token really belongs to Globex.
Introspect the access token (see Token Introspection).
You'll notice that the token represents Globex's administrator, not an Acme user.
Even though the OAuth application belongs to Acme, the access token is:
- issued at Globex's organization endpoint
- scoped to Globex
- authorized as a Globex user
This separation is what allows one application to safely serve many customer organizations.
Automating the same flow
The previous section used a browser so you could see the authorization flow.
For automated testing or CI pipelines, you can perform the same OAuth flow entirely with curl.
CODE_VERIFIER="a-random-verifier-at-least-43-characters-long"
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
# 1. Log in as Globex's administrator.
curl -s -c cookies.txt -X POST \
"http://localhost:8080/t/$TENANT/org/globex/login" \
--data-urlencode "email=admin@globex.example" \
--data-urlencode "password=correct-horse-battery-staple" \
-o /dev/null -D -
# 2. Start the authorization request.
LOCATION=$(curl -s -b cookies.txt -o /dev/null -D - \
--get "http://localhost:8080/t/$TENANT/org/globex/oauth2/authorize" \
--data-urlencode "response_type=code" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "redirect_uri=https://oidcdebugger.com/debug" \
--data-urlencode "scope=openid users:read" \
--data-urlencode "code_challenge=$CODE_CHALLENGE" \
--data-urlencode "code_challenge_method=S256" \
| grep -i '^location:')
CODE=$(printf '%s' "$LOCATION" | sed -n 's/.*[?&]code=\([^&[:space:]]*\).*/\1/p')
# 3. Exchange the authorization code.
curl -s -X POST \
"http://localhost:8080/t/$TENANT/org/globex/oauth2/token" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=$CODE" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "redirect_uri=https://oidcdebugger.com/debug" \
--data-urlencode "code_verifier=$CODE_VERIFIER"
The resulting access token is identical to the one obtained through the browser flow.
Cleaning up
If you no longer want Globex to use Acme Console, remove the application share.
o2idctl applications unshare "$APP_ID" --organizations globex
Removing the share prevents future sign-ins through that application. Existing access tokens continue to work until they naturally expire.
What you learned
In this tutorial you:
- created an OAuth application
- onboarded a customer Organization, and gave it its first user without a separate login of your own
- shared the application with that Organization
- authenticated as one of the Organization's users
- verified that O2ID issued a token for the Organization rather than the parent tenant
This pattern lets a single application securely serve many customer organizations without creating a separate OAuth client for each one.
To explore further:
- Organizations — Delegated administration covers
organizations:managein full — the scope that let you create Globex's first user in Step 2 with no account of your own at Globex. - Organizations — Shared applications explains
--policy all(sharing with every current and future Organization at once) and restricting permissions with--scope. - Organizations — Grants covers a related feature that allows individual users—not applications—to act in another Organization using an Organization switch grant.