Microsoft Teams SSO and the On-Behalf-Of Flow with Microsoft Graph
A practical, end-to-end guide to single sign-on for Microsoft Teams apps that call Microsoft Graph. Covers the on-behalf-of (OBO) flow, Microsoft Entra ID app registration, the Teams manifest, the client-side token request, the backend token exchange, and the failure modes that make this flow hard to debug the first time.
You built a Microsoft Teams (MSTeams) tab. It works. You demo it, and the first real user asks the question every MSTeams developer eventually hears: “Why am I being asked to sign in again? I’m already in Teams.”
That’s the wall this article is about. Once you decide to fix it, you land in the middle of Microsoft Teams single sign-on (SSO) and the on-behalf-of (OBO) flow, and you discover something annoying: Microsoft’s documentation covers every piece of this flow in a different location, and nowhere in one place does it assemble the full picture. The failures are silent. The error messages are some of the worst in the platform. And the “sample” you eventually find is usually two versions out of date.
Here’s what I want to do in this post. I’ll walk you end-to-end through how SSO actually works in an MSTeams app that calls Microsoft Graph (MSGraph), why the on-behalf-of flow exists, how to wire up Microsoft Entra ID (Entra ID), the Teams manifest, the tab, and the backend, and how to debug it when it breaks. I’m assuming you’ve already built a working tab and understand basic OAuth. If you want context on why MSTeams apps matter in the enterprise before you invest in this, I wrote about that separately. Otherwise, let’s dig in.
This post is a spoke of the Microsoft Graph for Microsoft 365 Developers hub. The hub article keeps the MSTeams + SSO section deliberately brief and links here for the deep dive.
Why Teams SSO exists (and what problem it actually solves)
Let me be clear about one thing first: MSTeams SSO is not a magic single login. It doesn’t remove authentication. What it does is reuse the Entra ID session the user already has inside MSTeams so your app can skip its own sign-in prompt.
Before SSO, the typical pattern was the “popup OAuth” flow. Your tab loaded, popped a new window, the user signed into Entra ID a second time, your app got a token, then you called MSGraph. It works. It also feels broken, because the user is already signed in. They don’t know or care that your tab is a separate app registration with its own token. They just see themselves being asked to prove who they are twice.
SSO closes that gap. The Teams client hands your tab a token on request, without a popup, as long as you’ve wired the pieces up correctly. That token, though, is for your app, not for MSGraph. And that’s where OBO comes in.
Here’s the whole flow end-to-end. The step to keep your eye on is the OBO exchange between your backend and Entra ID — the rest of the article keeps coming back to it.
The two-token problem and the OBO flow
Here’s the thing that trips up every developer the first time: the token MSTeams gives your tab is not a token you can use against MSGraph. It’s signed by Entra ID, it’s a real JWT, and it looks legitimate when you decode it. But its audience (aud) is your app’s Application ID URI, and its scope (scp) is the custom scope you exposed (by convention, access_as_user). MSGraph will reject it on sight because it’s not for MSGraph.
To talk to MSGraph, you need a second token whose audience is https://graph.microsoft.com and whose scopes match the permissions you requested (for example, User.Read, Mail.Read). Entra ID will gladly give you that token, but only if you can prove the user already authorized your app to act for them. That proof is the Teams token you already have.
This is what the on-behalf-of flow is for. Your backend takes the Teams token, calls Entra ID’s token endpoint with it as a user assertion, and gets back a fresh MSGraph-scoped token. That’s the whole trick. I call this the two-token problem, and naming it makes it dramatically easier to reason about everything else in this article.
| Teams token | Graph token | |
|---|---|---|
aud | api://<your-app>/<client-id> | https://graph.microsoft.com |
scp | access_as_user | User.Read Mail.Read |
| Issued for | your app | Microsoft Graph |
| How you get it | getAuthToken() in the tab | the OBO exchange on your backend |
These are two different tokens with two different audiences. You cannot skip the exchange.
Why can’t the browser do this exchange directly? Two reasons. First, the OBO call requires a client credential (a secret or certificate) that must never leave the server. Second, the OBO grant type is specifically defined for confidential clients; public clients aren’t allowed to use it. So your backend has to be in the picture. No backend, no OBO, no MSGraph call on behalf of the signed-in user.
If you want a refresher on the underlying protocol, I’ve covered the OAuth2 and OpenID Connect basics and the different OAuth2 flows supported in Entra ID in separate posts.
Setting up the Entra ID app registration
The Entra ID app registration is the single most error-prone part of this whole thing, and it’s where most of the silent failures live. There are four areas that matter for MSTeams SSO + OBO:
- Expose an API. This is where you define the Application ID URI (for example,
api://<tenant>.azurewebsites.net/<client-id>) and add theaccess_as_userscope. You also pre-authorize the MSTeams client IDs here so the Teams client can get a token for your app without prompting the user. - API permissions. These are the MSGraph delegated scopes your app will request later in the OBO exchange. Pick the minimum you actually need. If you eventually want
Mail.Read, add it here. - Authentication. Set the redirect URIs. For a tab, the auth-start page URL is the one that matters for the incremental consent fallback.
- Certificates & secrets. This is the client credential your backend uses to prove itself during OBO. In production, use a certificate. In dev, a client secret is fine.
Single multi-tenant app registration vs. two separate registrations comes up a lot. My default is a single multi-tenant registration that exposes the API and holds the MSGraph permissions. It’s simpler, it’s less to keep in sync, and the consent surface is consolidated. Use two registrations only when you have a separate service layer with a different lifecycle than your Teams app. If you’re new to the single vs. multi-tenant decision, I covered that here. And if you want the broader delegated-vs-app-only framing, this post walks through your auth flow options in Entra ID.
Here’s the relevant slice of the app registration manifest, showing the three pieces that have to line up — the exposed scope, the pre-authorized Teams/Office clients, and the Graph permissions you’ll request in the OBO exchange:
1{
2 "identifierUris": [
3 "api://your-tab.contoso.com/00000000-1111-2222-3333-444444444444"
4 ],
5 "api": {
6 "oauth2PermissionScopes": [{
7 "id": "b1e3f5a7-0000-0000-0000-generate-a-guid",
8 "value": "access_as_user",
9 "type": "User",
10 "adminConsentDisplayName": "Access the API as the signed-in user",
11 "adminConsentDescription": "Allows Teams to call the app's web API as the signed-in user.",
12 "isEnabled": true
13 }],
14 "preAuthorizedApplications": [
15 {
16 "appId": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
17 "delegatedPermissionIds": ["b1e3f5a7-0000-0000-0000-generate-a-guid"]
18 },
19 {
20 "appId": "5e3ce6c0-2b1f-4285-8d4b-75ee78787346",
21 "delegatedPermissionIds": ["b1e3f5a7-0000-0000-0000-generate-a-guid"]
22 }
23 ]
24 },
25 "requiredResourceAccess": [
26 {
27 "resourceAppId": "00000003-0000-0000-c000-000000000000",
28 "resourceAccess": [
29 { "id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d", "type": "Scope" },
30 { "id": "570282fd-fa5c-430d-a7fd-fc8dc98a9dca", "type": "Scope" }
31 ]
32 }
33 ]
34}Mapping each block back to the four areas above:
identifierUrisis your Application ID URI from Expose an API — the value the Teams manifest’sresourcehas to match character for character.oauth2PermissionScopesis theaccess_as_userscope. Generate a fresh GUID for itsidand reuse that same GUID in everypreAuthorizedApplicationsentry.preAuthorizedApplicationslists the two well-known Teams client IDs —1fec8e78-…(Teams desktop/mobile) and5e3ce6c0-…(Teams web). Pre-authorizing them is what lets the host issue a token silently, with no consent prompt. If your app also runs in Outlook and Office (a Microsoft 365 app), add the corresponding Office/M365 host client IDs here too.requiredResourceAccesspoints at Microsoft Graph (00000003-0000-0000-c000-000000000000) and requests the delegatedUser.ReadandMail.Readscopes you’ll exchange for later.
One registration feeds three places downstream — keep this map in your head and most of the “why doesn’t this line up?” bugs disappear:
- Expose an API (Application ID URI +
access_as_user) → the Teams manifest’swebApplicationInfo - Certificates & secrets (plus the client ID) → your backend’s MSAL configuration
- API permissions (the Graph delegated scopes) → the OBO exchange that calls Microsoft Graph
And Authentication (redirect / auth-start URIs) feeds the incremental-consent fallback covered later.
Configuring the Teams app manifest
The MSTeams app manifest has a tiny but critical block called webApplicationInfo. It’s two fields, and both of them have to line up exactly with the Entra ID app registration from the previous section.
1"webApplicationInfo": {
2 "id": "00000000-1111-2222-3333-444444444444",
3 "resource": "api://your-tab.contoso.com/00000000-1111-2222-3333-444444444444"
4}id is the app registration’s client ID. resource is the Application ID URI exactly as it appears in Expose an API — same host, same casing, and (this is the one that bites everyone) the same trailing slash, or lack of one.
Two things go wrong here constantly:
- Trailing slash mismatch on the
resourceURI. If your Application ID URI isapi://contoso.com/abcand your manifest hasapi://contoso.com/abc/, the exchange will fail silently. Teams will happily hand you a token with the wrong audience and the OBO step will reject it. validDomainsmissing your tab’s host. The tab content URL has to be listed, otherwise the SDK won’t initialize properly andgetAuthToken()will fail before it even gets to Entra ID.
If you’re coming from a SharePoint Framework (SPFx) background and wondering whether you can do this from SPFx instead of a native Teams app, I have an opinion on that. Short version: for anything beyond a lightweight Teams tab, go native.
The client-side: requesting the Teams token
The client side is the easy part, which is surprising given how hard everything else is. In your tab, initialize the Teams JavaScript SDK and call microsoftTeams.authentication.getAuthToken(). That’s it. The SDK talks to the Teams host, the host talks to Entra ID, and you get a JWT back.
1import { app, authentication } from "@microsoft/teams-js";
2
3export async function callBackend(): Promise<MeResponse> {
4 await app.initialize();
5
6 let teamsToken: string;
7 try {
8 teamsToken = await authentication.getAuthToken();
9 } catch (err) {
10 // SSO unavailable (e.g. running outside the Teams host).
11 // Fall back to a "Sign in" button that runs the interactive popup flow.
12 throw new SsoUnavailableError(err);
13 }
14
15 const res = await fetch("/api/me", {
16 headers: { Authorization: `Bearer ${teamsToken}` },
17 });
18 if (!res.ok) throw new Error(`Backend returned ${res.status}`);
19 return res.json();
20}What do you do with that token? You do exactly one thing: you send it to your backend over HTTPS with Authorization: Bearer <token>. Don’t try to use it against MSGraph. It’s not for MSGraph. It’s for your own API. Your backend is where the real work happens.
Before your backend uses the token for anything, it has to validate it: correct issuer, correct audience, valid signature, not expired. I wrote a dedicated post on validating Entra ID OAuth tokens because it’s a deep enough topic to deserve its own walkthrough. Don’t skip validation. A token that looks fine on decode can still be forged, replayed, or issued by the wrong tenant.
The backend: exchanging the token via OBO
This is the center of the whole flow. Once your backend has the validated Teams token, it calls Entra ID’s token endpoint with it as a user assertion and asks for a new token scoped to MSGraph. MSAL Node does this with one method: acquireTokenOnBehalfOf.
What you pass in:
- Your Entra ID app registration’s client ID.
- A client credential (certificate in production, client secret in dev).
- The incoming Teams token as the
oboAssertion. - The MSGraph scopes you actually need for this call (
User.Read,Mail.Read, and so on).
What you get back, on the happy path, is a token with aud: https://graph.microsoft.com and the scopes you requested. MSAL also caches this for you, which matters more than people realize: don’t build your own cache layer on top of MSAL.
Here’s a complete Azure Functions HTTP handler (Node, v4 programming model) that validates the incoming token, then exchanges it on behalf of the user:
1import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
2import { ConfidentialClientApplication, InteractionRequiredAuthError } from "@azure/msal-node";
3import { createRemoteJWKSet, jwtVerify } from "jose";
4
5const TENANT_ID = process.env.TENANT_ID!;
6const CLIENT_ID = process.env.CLIENT_ID!;
7// api://your-tab.contoso.com/<client-id>
8const APP_ID_URI = process.env.APP_ID_URI!;
9
10const cca = new ConfidentialClientApplication({
11 auth: {
12 clientId: CLIENT_ID,
13 authority: `https://login.microsoftonline.com/${TENANT_ID}`,
14 // dev only — use a certificate in production
15 clientSecret: process.env.CLIENT_SECRET,
16 },
17});
18
19// Entra ID's signing keys, cached and rotated for you.
20const jwks = createRemoteJWKSet(
21 new URL(`https://login.microsoftonline.com/${TENANT_ID}/discovery/v2.0/keys`)
22);
23
24export async function me(req: HttpRequest, ctx: InvocationContext): Promise<HttpResponseInit> {
25 const authHeader = req.headers.get("authorization");
26 if (!authHeader?.startsWith("Bearer ")) return { status: 401 };
27 const teamsToken = authHeader.slice("Bearer ".length);
28
29 // 1) Validate the incoming Teams token before trusting it.
30 try {
31 await jwtVerify(teamsToken, jwks, {
32 audience: [CLIENT_ID, APP_ID_URI],
33 issuer: `https://login.microsoftonline.com/${TENANT_ID}/v2.0`,
34 });
35 } catch {
36 return { status: 401 };
37 }
38
39 // 2) Exchange it, on behalf of the user, for a Microsoft Graph token.
40 try {
41 const result = await cca.acquireTokenOnBehalfOf({
42 oboAssertion: teamsToken,
43 scopes: ["User.Read", "Mail.Read"],
44 });
45
46 // 3) Call Graph server-side and return only the data the tab needs.
47 // The Graph access token never leaves the backend.
48 const me = await callGraphMe(result!.accessToken); // defined in the next section
49 return { jsonBody: me };
50 } catch (err) {
51 // The user hasn't consented yet: MSAL throws InteractionRequiredAuthError
52 // (this is the AADSTS65001 case). Tell the tab to run incremental consent.
53 if (err instanceof InteractionRequiredAuthError) {
54 return { status: 403, jsonBody: { error: "consent_required" } };
55 }
56 ctx.error(err);
57 return { status: 500 };
58 }
59}
60
61app.http("me", { methods: ["GET"], authLevel: "anonymous", handler: me });On the unhappy path, you’ll see interaction_required with AADSTS65001 underneath. That’s the single most common error in this flow, and it doesn’t mean something is broken. It means the user hasn’t consented to the MSGraph scopes yet. Handle it explicitly, return a structured error to the tab, and let the client kick off the incremental consent dialog. More on that in the consent section below.
For readers on the .NET side, the MSAL.NET equivalent is IConfidentialClientApplication.AcquireTokenOnBehalfOf. Same shape, same parameters, same error conditions.
1var app = ConfidentialClientApplicationBuilder
2 .Create(clientId)
3 // use .WithCertificate(cert) in production
4 .WithClientSecret(clientSecret)
5 .WithAuthority($"https://login.microsoftonline.com/{tenantId}")
6 .Build();
7
8AuthenticationResult result = await app
9 .AcquireTokenOnBehalfOf(
10 new[] { "User.Read", "Mail.Read" },
11 new UserAssertion(incomingTeamsToken))
12 .ExecuteAsync();If you want more context on what a practical Azure Functions + MSTeams tab project looks like (even outside the OBO specifics), this post on removing Azure Functions from a TTK project is a useful reference point for the file layout and wiring.
Calling Microsoft Graph with the exchanged token
This is the short section, and I’m keeping it short on purpose. Once your backend has the MSGraph-scoped token, calling MSGraph is just an HTTP call. You can use fetch, the MSGraph SDK, or anything else that speaks HTTPS.
1import { Client } from "@microsoft/microsoft-graph-client";
2
3// Runs on the backend with the OBO-exchanged Graph token from the previous
4// step. The token stays server-side; the tab only ever sees the returned data.
5async function callGraphMe(graphToken: string) {
6 const graph = Client.init({
7 authProvider: (done) => done(null, graphToken),
8 });
9
10 const profile = await graph.api("/me").get();
11 const messages = await graph.api("/me/messages").top(5).get();
12 return { profile, messages: messages.value };
13}That’s it. No magic. The hard work was everything that came before. If your /me call works, your SSO + OBO chain is wired correctly.
Consent: what happens the first time a user installs your app
There are two consent moments in this flow, and mixing them up is how most developers lose an afternoon.
Admin consent happens when an Entra ID admin grants your app’s requested MSGraph permissions for the entire tenant. After admin consent, individual users never see a consent dialog for those scopes. For enterprise deployments, this is the path you want. Provide your admin consent URL in your documentation and your app’s “first run” instructions.
Incremental user consent happens when a user installs your app in a tenant where admin consent hasn’t been given, or when you request a scope that wasn’t part of the original consent. The first OBO call will fail with interaction_required + AADSTS65001. Your backend returns that structured error to the tab, and the tab opens a consent popup using microsoftTeams.authentication.authenticate() pointing at your auth-start page. The user consents, the popup closes, and the tab retries the original call.
Once you see consent as two separate paths, the error code stops feeling like a bug and starts feeling like a branch in your state machine.
Debugging the flow when it goes wrong
Here’s where I’m going to be honest with you. Microsoft’s error messages in this flow are some of the worst in the platform. interaction_required is accurate. AADSTS500011 is not. You’ll spend time. The trick is knowing the shape of the failure modes so you can map what you see to what’s actually wrong.
The short list of things that break, ordered roughly by how often I hit them:
- Manifest
resourceURI doesn’t exactly match the Application ID URI. Silent failure. Token comes back with the wrong audience. Fix: copy/paste both values, compare character by character, watch for trailing slashes. access_as_userscope not exposed on the app registration.getAuthToken()returns an error. Fix: add it under “Expose an API.”- MSTeams client IDs not pre-authorized. The Teams host refuses to issue a token silently. Fix: add the MSTeams client IDs to the pre-authorized list on your Expose an API page.
- Incoming Teams token audience is wrong. Your backend validation throws. Fix: usually a mismatch between what your backend expects and what’s in the manifest’s
webApplicationInfo.resource. AADSTS65001on the OBO call. Not broken, just needs consent. Trigger the incremental consent flow.- Client secret expired. OBO call fails with an auth error from Entra ID. Fix: rotate the secret (or move to a certificate, which is what you should be doing in production anyway).
- Certificate not uploaded correctly. Public-key mismatch on the Entra ID side. Fix: re-upload and confirm the thumbprint matches the one in your MSAL config.
- Conditional Access policy blocks the OBO call. You’ll see a CA-specific claims challenge. Fix: either adjust the policy or implement the CAE claims-challenge handling path.
Tools that shorten the loop:
- jwt.io to paste and decode any token you suspect. Always check
aud,iss,scp. - Entra ID sign-in logs for the tenant. The “Service principal sign-ins” and “User sign-ins” views will show you the exact failure code and sometimes a hint.
- MSAL logging turned to verbose on the backend. It will tell you what grant type it’s using, which endpoint it’s hitting, and what claim is missing.
When it breaks, walk the chain in order — the first “no” is your answer:
Recap
Here’s the mental model, compressed to three sentences. There are two tokens: the one MSTeams gives your tab, and the one MSGraph needs. The bridge between them is the on-behalf-of flow, and the bridge only runs on a backend that can prove itself with a client credential. Everything else in this article is wiring that up correctly and recognizing the failure modes when you get the wiring wrong.
If you want the broader MSGraph development picture, head back to the Microsoft Graph for Microsoft 365 Developers hub where this article lives as a spoke. If you’re new to MSTeams development and want a guided path rather than piecing it together post by post, my free Microsoft Teams app dev onramp email course walks through the full landscape in nine days.
What’s the weirdest MSTeams SSO failure you’ve hit? I’ve got a strong suspicion AADSTS65001 is still the most common one, but the trailing-slash silent-audience-mismatch bug is close behind. Leave a comment, I want to hear it.