SSO for Teams Apps: On-Behalf-Of Flow Without the Pain
Teams SSO lets your tab or bot get a token silently and call Graph or your own APIs as the user. Here's the on-behalf-of flow, set up so it actually works.
- #microsoft-teams
- #sso
- #azure-ad
- #on-behalf-of
- #graph-api
- #security
The fastest way to kill adoption of a Teams app is to make people log in again. They’re already signed into Teams with their work identity — asking them to authenticate a second time inside the tab feels broken, because it is. Teams SSO fixes this: your tab or bot can silently obtain a token for the signed-in user and exchange it, via the on-behalf-of (OBO) flow, for a token to call Microsoft Graph or your own backend. It’s powerful and it’s fiddly, and almost every failure traces back to one of three setup mistakes. Here’s the whole path.
What SSO actually gives you
When SSO works, your client-side code calls one SDK method and gets a token — no popup, no redirect:
import { authentication } from "@microsoft/teams-js";
const ssoToken = await authentication.getAuthToken();
// send ssoToken to your backend in the Authorization header
But that ssoToken is scoped to your app’s API, not to Graph. You can’t call Graph with it directly. That’s where OBO comes in: your backend swaps the incoming token for a downstream token.
The three setup steps everyone gets wrong
SSO failing silently is almost always one of these:
1. Expose an API and set the Application ID URI. In your Azure AD app registration, under “Expose an API,” set the Application ID URI to the Teams-required format: api://<your-domain>/<app-client-id> (for tabs hosted on a domain) or the bot-specific format for bots. Add a scope named access_as_user. Get the URI format wrong and getAuthToken() returns a consent error.
2. Pre-authorize the Teams client IDs. Under the same “Expose an API,” add the two Teams client application IDs as authorized client applications for your access_as_user scope. Skip this and Teams won’t issue the token silently — the user gets an interactive consent prompt every time, defeating the point.
3. Mirror it in the manifest. The Teams app manifest needs webApplicationInfo with your app’s client ID and resource (the Application ID URI):
{
"webApplicationInfo": {
"id": "<app-client-id>",
"resource": "api://devops.example.com/<app-client-id>"
}
}
All three must agree. A mismatch between the manifest resource and the Azure AD Application ID URI is the single most common reason SSO “just doesn’t work.”
The on-behalf-of exchange
Your backend receives the SSO token, validates it, then exchanges it for a Graph token using the OBO grant. With MSAL on the server:
const { ConfidentialClientApplication } = require("@azure/msal-node");
const cca = new ConfidentialClientApplication({
auth: {
clientId: process.env.AAD_CLIENT_ID,
clientSecret: process.env.AAD_CLIENT_SECRET,
authority: `https://login.microsoftonline.com/${tenantId}`,
},
});
async function getGraphToken(ssoToken) {
const result = await cca.acquireTokenOnBehalfOf({
oboAssertion: ssoToken,
scopes: ["https://graph.microsoft.com/User.Read"],
});
return result.accessToken;
}
Now you call Graph as the user, with only the permissions they’ve consented to — not an all-powerful application token.
Handle the consent fallback gracefully
OBO can fail with an interaction_required error when the user hasn’t consented to a downstream scope yet. This is expected the first time. Catch it, tell the client to run the interactive consent flow once, and from then on the silent path works:
try {
return await getGraphToken(ssoToken);
} catch (err) {
if (err.errorCode === "invalid_grant" || err.suberror === "consent_required") {
return { needsConsent: true, scopes: ["User.Read"] };
}
throw err;
}
On the client, when you get needsConsent, call authentication.authenticate() to pop a consent window pointed at your AAD consent URL. After that one-time grant, the experience is silent forever.
Bots use SSO too
The same flow works for bots, but the trigger is different: you configure an OAuth connection on the Azure Bot resource and the bot SDK surfaces a token via the token service. For message extensions and adaptive card actions that need the user’s identity, this is how you avoid a sign-in card on every interaction. The principle is identical — get a user-scoped token, OBO it for what you actually need to call.
Least privilege still applies
SSO makes it easy to request broad scopes, which is exactly why you should resist. Two habits:
- Request the narrowest Graph scopes that do the job.
User.Readto greet someone by name; don’t requestDirectory.Read.Allbecause it might be handy. Every scope you add is a consent the user (or an admin) has to approve, and broad scopes get apps blocked by tenant admins. - Validate the incoming token. Check the audience (
aud) matches your Application ID URI and the issuer is your tenant before you trust it. The SSO token is a bearer token; treat it like one.
Where this fits
SSO is the difference between a Teams app that feels native and one that feels bolted on. The flow itself is just “get token, OBO it” — the work is the three-way agreement between Azure AD, the manifest, and your scopes. Get the Application ID URI right, pre-authorize the Teams client IDs, mirror it in webApplicationInfo, and handle the one-time consent. For the message-extension and tab handlers that consume these tokens, see the related Teams guides, plus token-validation snippets in the prompt library and more in the Microsoft Teams category.
Azure AD app registration UI and MSAL APIs change; verify the Application ID URI format and OBO scopes against current Microsoft identity docs before shipping.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.