Skip to main content
← Back to blog

Sharing One OAuth App Across Customer Organizations

· 8 min read
O2ID Team
Maintainers

If you're building a B2B SaaS product, you'll eventually have to figure out how your customers' users sign in. Your own team already signs in through an OAuth application registered in your O2ID tenant, and as new customers onboard it's tempting to just register another application per customer. That works fine for a handful of customers, and falls over well before you reach a hundred.

O2ID's answer is to let you share a single OAuth application with customer Organizations: each organization's users authenticate with their own credentials, and O2ID still issues tokens scoped entirely to that organization. That's what this tutorial sets up.

What you'll build

Acme runs a SaaS product called Acme Console, with its identity managed in an O2ID tenant with the slug acme. One of its customers, Globex Corp, has been onboarded as an Organization inside that 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

Globex never has to register an OAuth client of its own.

Before you begin

This tutorial assumes:

  • O2ID is running locally on http://localhost:8080
  • You're already authenticated with o2idctl
  • jq is 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_ID to share the application later.
  • CLIENT_ID when starting the OAuth authorization flow.

We didn't provide a client secret — this application is a public client, authenticating with 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 Str0ngPassw0rd9)
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

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.

FieldValue
Authorize URIthe URL printed above
Client ID$CLIENT_ID
Redirect URIhttps://oidcdebugger.com/debug
Scopeopenid users:read
Response Typecode
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: Str0ngPassw0rd9

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": "..."
}

You've just signed into Acme Console as a Globex user, through an OAuth application Globex never had to register.

Step 5: Verify the issued token

Introspect the access token (see Token Introspection) and check who it belongs to: 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 happen. For automated testing or CI pipelines, the same flow works 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=Str0ngPassw0rd9" \
-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: