{
  "schemaVersion": "1.0",
  "code": "9002326",
  "name": null,
  "title": "AADSTS9002326: Cross-origin token request from a non-SPA app (fix)",
  "url": "https://brambrinkmeier.com/entra/aadsts/9002326/",
  "alternates": {
    "markdown": "https://brambrinkmeier.com/entra/aadsts/9002326.md",
    "json": "https://brambrinkmeier.com/api/aadsts/9002326.json",
    "html": "https://brambrinkmeier.com/entra/aadsts/9002326/"
  },
  "description": "AADSTS9002326: a browser token request for an app not registered as an SPA. Not in Microsoft's error list. Causes, fixes and KQL.",
  "summary": "AADSTS9002326 means a browser sent a token request (it carried an Origin header) for an app that isn't registered as a single-page application. Microsoft's error reference doesn't list it. Typical causes: client credentials or a secret called from JavaScript, or an SPA whose redirect URI isn't the spa type. Low attack value. Fix the app registration or move the call to a backend.",
  "inMicrosoftReference": false,
  "unlistedChecked": "2026-10-30",
  "meaningText": "Microsoft's [error code reference](https://learn.microsoft.com/entra/identity-platform/reference-error-codes) doesn't list AADSTS9002326 (checked 30 October 2026).\n\nThe lab reproduced it. A password-grant token request from a public client, sent with the header `Origin: https://lab.brambrinkmeier.com`, returned:\n\n> AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'https://lab.brambrinkmeier.com'.\n\nEntra treats a token request that carries an `Origin` header as one made from a browser. It accepts such a request only when the app is set up as a single-page application (SPA). Microsoft's wording below links the header to the browser.\n\nMicrosoft describes the message, without the code number, in its authorization code flow reference:\n\n> Single page apps may receive an `invalid_request` error indicating that cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. This indicates that the redirect URI used to request the token has not been marked as a `spa` redirect URI.\n\nThe same page explains why the check exists:\n\n> Applications can't use a `spa` redirect URI with non-SPA flows, for example, native applications or client credential flows. [...] the Microsoft identity platform also prevents the use of client credentials in all flows in the presence of an `Origin` header, to ensure that secrets aren't used from within the browser.\n\nOur reading of the lab result: the check is not only about secrets. The lab request used the password grant from a public client, with no secret, and still got 9002326. So any token request with an `Origin` header from an app that isn't an SPA can hit it.",
  "sections": [
    {
      "heading": "What does AADSTS9002326 mean?",
      "text": "Microsoft's [error code reference](https://learn.microsoft.com/entra/identity-platform/reference-error-codes) doesn't list AADSTS9002326 (checked 30 October 2026).\n\nThe lab reproduced it. A password-grant token request from a public client, sent with the header `Origin: https://lab.brambrinkmeier.com`, returned:\n\n> AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'https://lab.brambrinkmeier.com'.\n\nEntra treats a token request that carries an `Origin` header as one made from a browser. It accepts such a request only when the app is set up as a single-page application (SPA). Microsoft's wording below links the header to the browser.\n\nMicrosoft describes the message, without the code number, in its authorization code flow reference:\n\n> Single page apps may receive an `invalid_request` error indicating that cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. This indicates that the redirect URI used to request the token has not been marked as a `spa` redirect URI.\n\nThe same page explains why the check exists:\n\n> Applications can't use a `spa` redirect URI with non-SPA flows, for example, native applications or client credential flows. [...] the Microsoft identity platform also prevents the use of client credentials in all flows in the presence of an `Origin` header, to ensure that secrets aren't used from within the browser.\n\nOur reading of the lab result: the check is not only about secrets. The lab request used the password grant from a public client, with no secret, and still got 9002326. So any token request with an `Origin` header from an app that isn't an SPA can hit it."
    },
    {
      "heading": "Reproduced in the lab",
      "text": "On 30 October 2026 an automated lab run sent a password-grant (ROPC) token request from a public client app in my lab tenant. The request carried the header `Origin: https://lab.brambrinkmeier.com`. Entra returned `AADSTS9002326` with the first line quoted above.\n\nThat confirms the code and its wording at the token endpoint. The message names the origin it saw, which helps you find the page that sent the call. It lands in **`SigninLogs`** (interactive user sign-ins): one row per lab run, Status Failure, Conditional Access Not Applied, client app \"Mobile Apps and Desktop clients\". Entra's explanation on the row (the \"additional details\" field) reads: *\"The application must fix either the reply URIs registered on the application registration to include a unique reply address of type \"spa\", or they must fix the token request to not include an Origin header, if being sent from a non-browser client.\"* That is Microsoft's own description of the fix, which its error reference doesn't carry. The KQL below was run on these rows ([how](https://brambrinkmeier.com/methodology/)). Both queries found the lab rows."
    },
    {
      "heading": "Is it an attack?",
      "text": "Almost always no. It is a mismatch between where the code runs (a browser) and how the app is registered.\n\n| Pattern | Likely meaning |\n|---|---|\n| Works in Postman, fails from the web page | Users report exactly this ([Q&A 1534740](https://learn.microsoft.com/en-us/answers/questions/1534740/cross-origin-token-redemption-is-permitted-only-fo), [Q&A 1608608](https://learn.microsoft.com/en-us/answers/questions/1608608/cross-origin-token-redemption-is-permitted-only-fo)). Our reading: Postman sends no `Origin` header; the browser does. |\n| A script on a SharePoint page or a front end calls `client_credentials` with a secret | Users report it on this call (Q&A 1534740). The secret is in browser code, which Microsoft blocks on purpose. Treat the secret as exposed. |\n| An SPA using MSAL.js fails on the code redemption step | The redirect URI is registered as **Web**, not **Single-page application**. This is the cause Microsoft describes. |\n| A hybrid mobile app (Ionic/Capacitor) fails with origin `capacitor://localhost` | Users report it ([Ionic forum](https://forum.ionicframework.com/t/capacitor-msal-azure-b2c-ios-not-working-aadsts9002326/240436)). Likely the app's web view sends an `Origin` header, so Entra treats it as a browser (unconfirmed). |\n\nAs far as we can tell, the error gives an attacker nothing. The request fails before a token is issued. The one security point is the second row. If a client secret sits in JavaScript, anyone who opens the page can read it. Rotate it."
    },
    {
      "heading": "How do I find it in my logs (KQL)?",
      "text": "Microsoft doesn't document which sign-in table 9002326 is logged in. Check where it lands in your workspace:\n\n```kusto\nunion withsource=SourceTable SigninLogs, AADNonInteractiveUserSignInLogs, AADServicePrincipalSignInLogs\n| where TimeGenerated > ago(30d)\n| where ResultType == \"9002326\"\n| summarize Rows = count(), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated) by SourceTable, AppId\n```\n\nWhich apps fail, and from which clients. `UserAgent` often tells you it is a browser:\n\n```kusto\nunion withsource=SourceTable SigninLogs, AADNonInteractiveUserSignInLogs, AADServicePrincipalSignInLogs\n| where TimeGenerated > ago(30d)\n| where ResultType == \"9002326\"\n| summarize Rows = count(),\n            IPs = dcount(IPAddress),\n            Agents = make_set(UserAgent, 5),\n            Descriptions = make_set(ResultDescription, 3)\n          by SourceTable, AppId\n| sort by Rows desc\n```\n\nIf both queries return nothing, the rows may not be written to your tenant's logs at all. That is also undocumented. The message itself, with its `Request origin`, is then your best evidence."
    },
    {
      "heading": "How do I fix AADSTS9002326?",
      "text": "**For the user:** you can't fix this yourself. Report the app name, the page you were on and the time to whoever runs the app.\n\n**For developers:**\n1. **If the app is an SPA**, register the redirect URI under **Authentication** > **Add a platform** > **Single-page application**, not under **Web**. Microsoft's auth code flow page says to *\"update the redirect URI for your app to use the `spa` type\"*. Use the authorization code flow with PKCE, for example with MSAL.js. Microsoft notes PKCE is *\"required by the Microsoft identity platform for single page apps using the authorization code flow\"*.\n2. **If browser code calls `client_credentials` or sends a client secret**, move that call to a backend. Microsoft's guidance is plain: *\"Don't use the application secret in a native app or single page app because a `client_secret` can't be reliably stored on devices or web pages.\"* The browser then calls your backend, and the backend calls Entra.\n3. **If browser code uses the password grant**, stop. Use an interactive sign-in with MSAL.js instead.\n4. **For hybrid mobile apps**, our reading is that the web view counts as a browser. Either register the app as an SPA and use a browser-style flow, or use a native MSAL library so the token request doesn't come from the web view. Test both; the Ionic thread has no replies and no confirmed fix.\n\n**For admins:**\n1. Look up the app by the `AppId` from the queries, or by the client ID in the error.\n2. In **App registrations** > the app > **Authentication**, check which platform the redirect URI is under.\n3. If the app had a secret in browser code, create a new secret, update the backend that should hold it, and delete the old one."
    }
  ],
  "microsoftWording": {
    "text": "AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'https://lab.brambrinkmeier.com'.",
    "source": "Quoted on the page from the lab or from user reports; the code is not in Microsoft's error code reference",
    "sourceUrl": null
  },
  "securityReading": {
    "category": {
      "id": "app-config",
      "label": "App configuration"
    },
    "kind": {
      "id": "config",
      "label": "Configuration error"
    },
    "attackSignal": {
      "level": "low",
      "label": "Low: usually configuration or user error"
    },
    "mitre": [],
    "tables": [],
    "isItAnAttack": "Almost always no. It is a mismatch between where the code runs (a browser) and how the app is registered.\n\n| Pattern | Likely meaning |\n|---|---|\n| Works in Postman, fails from the web page | Users report exactly this ([Q&A 1534740](https://learn.microsoft.com/en-us/answers/questions/1534740/cross-origin-token-redemption-is-permitted-only-fo), [Q&A 1608608](https://learn.microsoft.com/en-us/answers/questions/1608608/cross-origin-token-redemption-is-permitted-only-fo)). Our reading: Postman sends no `Origin` header; the browser does. |\n| A script on a SharePoint page or a front end calls `client_credentials` with a secret | Users report it on this call (Q&A 1534740). The secret is in browser code, which Microsoft blocks on purpose. Treat the secret as exposed. |\n| An SPA using MSAL.js fails on the code redemption step | The redirect URI is registered as **Web**, not **Single-page application**. This is the cause Microsoft describes. |\n| A hybrid mobile app (Ionic/Capacitor) fails with origin `capacitor://localhost` | Users report it ([Ionic forum](https://forum.ionicframework.com/t/capacitor-msal-azure-b2c-ios-not-working-aadsts9002326/240436)). Likely the app's web view sends an `Origin` header, so Entra treats it as a browser (unconfirmed). |\n\nAs far as we can tell, the error gives an attacker nothing. The request fails before a token is issued. The one security point is the second row. If a client secret sits in JavaScript, anyone who opens the page can read it. Rotate it.",
    "conditionalAccess": null
  },
  "kql": [
    {
      "title": null,
      "leadIn": "Microsoft doesn't document which sign-in table 9002326 is logged in. Check where it lands in your workspace:",
      "query": "union withsource=SourceTable SigninLogs, AADNonInteractiveUserSignInLogs, AADServicePrincipalSignInLogs\n| where TimeGenerated > ago(30d)\n| where ResultType == \"9002326\"\n| summarize Rows = count(), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated) by SourceTable, AppId"
    },
    {
      "title": null,
      "leadIn": "Which apps fail, and from which clients. `UserAgent` often tells you it is a browser:",
      "query": "union withsource=SourceTable SigninLogs, AADNonInteractiveUserSignInLogs, AADServicePrincipalSignInLogs\n| where TimeGenerated > ago(30d)\n| where ResultType == \"9002326\"\n| summarize Rows = count(),\n            IPs = dcount(IPAddress),\n            Agents = make_set(UserAgent, 5),\n            Descriptions = make_set(ResultDescription, 3)\n          by SourceTable, AppId\n| sort by Rows desc"
    }
  ],
  "kqlSectionText": "Microsoft doesn't document which sign-in table 9002326 is logged in. Check where it lands in your workspace:\n\n```kusto\nunion withsource=SourceTable SigninLogs, AADNonInteractiveUserSignInLogs, AADServicePrincipalSignInLogs\n| where TimeGenerated > ago(30d)\n| where ResultType == \"9002326\"\n| summarize Rows = count(), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated) by SourceTable, AppId\n```\n\nWhich apps fail, and from which clients. `UserAgent` often tells you it is a browser:\n\n```kusto\nunion withsource=SourceTable SigninLogs, AADNonInteractiveUserSignInLogs, AADServicePrincipalSignInLogs\n| where TimeGenerated > ago(30d)\n| where ResultType == \"9002326\"\n| summarize Rows = count(),\n            IPs = dcount(IPAddress),\n            Agents = make_set(UserAgent, 5),\n            Descriptions = make_set(ResultDescription, 3)\n          by SourceTable, AppId\n| sort by Rows desc\n```\n\nIf both queries return nothing, the rows may not be written to your tenant's logs at all. That is also undocumented. The message itself, with its `Request origin`, is then your best evidence.",
  "fix": {
    "short": "Developer: for an SPA, register the redirect URI under Single-page application, not Web, and use auth code flow with PKCE (MSAL.js). If browser code calls `client_credentials` or sends a secret, move that call to a backend and rotate the secret. User: report the app, page and time to its owner.",
    "steps": "**For the user:** you can't fix this yourself. Report the app name, the page you were on and the time to whoever runs the app.\n\n**For developers:**\n1. **If the app is an SPA**, register the redirect URI under **Authentication** > **Add a platform** > **Single-page application**, not under **Web**. Microsoft's auth code flow page says to *\"update the redirect URI for your app to use the `spa` type\"*. Use the authorization code flow with PKCE, for example with MSAL.js. Microsoft notes PKCE is *\"required by the Microsoft identity platform for single page apps using the authorization code flow\"*.\n2. **If browser code calls `client_credentials` or sends a client secret**, move that call to a backend. Microsoft's guidance is plain: *\"Don't use the application secret in a native app or single page app because a `client_secret` can't be reliably stored on devices or web pages.\"* The browser then calls your backend, and the backend calls Entra.\n3. **If browser code uses the password grant**, stop. Use an interactive sign-in with MSAL.js instead.\n4. **For hybrid mobile apps**, our reading is that the web view counts as a browser. Either register the app as an SPA and use a browser-style flow, or use a native MSAL library so the token request doesn't come from the web view. Test both; the Ionic thread has no replies and no confirmed fix.\n\n**For admins:**\n1. Look up the app by the `AppId` from the queries, or by the client ID in the error.\n2. In **App registrations** > the app > **Authentication**, check which platform the redirect URI is under.\n3. If the app had a secret in browser code, create a new secret, update the backend that should hold it, and delete the old one."
  },
  "labReproduced": {
    "date": "2026-10-30",
    "method": "ROPC and other token-endpoint requests against a lab tenant",
    "scenario": "Token request with a browser Origin header from a non-SPA client",
    "message": "AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'https://lab.brambrinkmeier.com'.",
    "logCaptured": "2026-10-30",
    "kqlTested": "2026-10-30"
  },
  "labSectionText": "On 30 October 2026 an automated lab run sent a password-grant (ROPC) token request from a public client app in my lab tenant. The request carried the header `Origin: https://lab.brambrinkmeier.com`. Entra returned `AADSTS9002326` with the first line quoted above.\n\nThat confirms the code and its wording at the token endpoint. The message names the origin it saw, which helps you find the page that sent the call. It lands in **`SigninLogs`** (interactive user sign-ins): one row per lab run, Status Failure, Conditional Access Not Applied, client app \"Mobile Apps and Desktop clients\". Entra's explanation on the row (the \"additional details\" field) reads: *\"The application must fix either the reply URIs registered on the application registration to include a unique reply address of type \"spa\", or they must fix the token request to not include an Origin header, if being sent from a non-browser client.\"* That is Microsoft's own description of the fix, which its error reference doesn't carry. The KQL below was run on these rows ([how](https://brambrinkmeier.com/methodology/)). Both queries found the lab rows.",
  "labRuns": [
    {
      "date": "2026-10-30",
      "run": "round 4",
      "method": "token-endpoint requests against a lab tenant",
      "issue": "https://github.com/GrantZero604/Brambrinkmeier.com/issues/67",
      "scenario": "Token request with a browser Origin header from a non-SPA client",
      "expected": "9002326",
      "got": "9002326",
      "matchedExpectation": true,
      "message": "AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'https://lab.brambrinkmeier.com'."
    },
    {
      "date": "2026-10-30",
      "run": "round 5",
      "method": "token-endpoint requests against a lab tenant, using lab-created test apps",
      "issue": "https://github.com/GrantZero604/Brambrinkmeier.com/issues/69",
      "scenario": "Token request with a browser Origin header from a non-SPA client",
      "expected": "9002326",
      "got": "9002326",
      "matchedExpectation": true,
      "message": "AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'https://lab.brambrinkmeier.com'."
    },
    {
      "date": "2026-10-30",
      "run": "round 5 (first attempt)",
      "method": "token-endpoint requests against a lab tenant",
      "issue": "https://github.com/GrantZero604/Brambrinkmeier.com/issues/68",
      "scenario": "Token request with a browser Origin header from a non-SPA client",
      "expected": "9002326",
      "got": "9002326",
      "matchedExpectation": true,
      "message": "AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'https://lab.brambrinkmeier.com'."
    },
    {
      "date": "2026-10-30",
      "run": "round 6",
      "method": "token-endpoint requests against a lab tenant, using lab-created test apps",
      "issue": "https://github.com/GrantZero604/Brambrinkmeier.com/issues/70",
      "scenario": "Token request with a browser Origin header from a non-SPA client",
      "expected": "9002326",
      "got": "9002326",
      "matchedExpectation": true,
      "message": "AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'https://lab.brambrinkmeier.com'."
    },
    {
      "date": "2026-10-30",
      "run": "round 7",
      "method": "token-endpoint requests against a lab tenant",
      "issue": "https://github.com/GrantZero604/Brambrinkmeier.com/issues/71",
      "scenario": "Token request with a browser Origin header from a non-SPA client",
      "expected": "9002326",
      "got": "9002326",
      "matchedExpectation": true,
      "message": "AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'https://lab.brambrinkmeier.com'."
    },
    {
      "date": "2026-10-30",
      "run": null,
      "method": "ROPC and other token-endpoint requests against a lab tenant",
      "issue": "https://github.com/GrantZero604/Brambrinkmeier.com/issues/66",
      "scenario": "Token request with a browser Origin header from a non-SPA client",
      "expected": "9002326",
      "got": "9002326",
      "matchedExpectation": true,
      "message": "AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'https://lab.brambrinkmeier.com'."
    },
    {
      "date": "2026-10-31",
      "run": "round 8",
      "method": "token-endpoint and device-code requests against a lab tenant",
      "issue": "https://github.com/GrantZero604/Brambrinkmeier.com/issues/72",
      "scenario": "Token request with a browser Origin header from a non-SPA client",
      "expected": "9002326",
      "got": "9002326",
      "matchedExpectation": true,
      "message": "AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'https://lab.brambrinkmeier.com'."
    },
    {
      "date": "2026-10-31",
      "run": "round 9",
      "method": "token-endpoint and device-code requests against a lab tenant",
      "issue": "https://github.com/GrantZero604/Brambrinkmeier.com/issues/73",
      "scenario": "Token request with a browser Origin header from a non-SPA client",
      "expected": "9002326",
      "got": "9002326",
      "matchedExpectation": true,
      "message": "AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'https://lab.brambrinkmeier.com'."
    }
  ],
  "verification": "lab-reproduced",
  "related": [
    "7000218",
    "7000215",
    "700016",
    "9002313",
    "9002327"
  ],
  "coveredBy": [],
  "sources": [
    {
      "title": "Microsoft Entra authentication and authorization error codes",
      "url": "https://learn.microsoft.com/entra/identity-platform/reference-error-codes"
    },
    {
      "title": "Microsoft identity platform and OAuth 2.0 authorization code flow",
      "url": "https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow"
    },
    {
      "title": "Microsoft Q&A: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type (1534740)",
      "url": "https://learn.microsoft.com/en-us/answers/questions/1534740/cross-origin-token-redemption-is-permitted-only-fo"
    },
    {
      "title": "Microsoft Q&A: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type (1608608)",
      "url": "https://learn.microsoft.com/en-us/answers/questions/1608608/cross-origin-token-redemption-is-permitted-only-fo"
    },
    {
      "title": "Ionic Forum: Capacitor MSAL Azure B2C - iOS not working AADSTS9002326",
      "url": "https://forum.ionicframework.com/t/capacitor-msal-azure-b2c-ios-not-working-aadsts9002326/240436"
    },
    {
      "title": "SigninLogs table reference (Azure Monitor)",
      "url": "https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/signinlogs"
    },
    {
      "title": "AADServicePrincipalSignInLogs table reference (Azure Monitor)",
      "url": "https://docs.azure.cn/en-us/azure-monitor/reference/tables/AADServicePrincipalSignInLogs"
    }
  ],
  "published": "2026-10-30",
  "lastUpdated": "2026-10-30",
  "lastVerified": null,
  "license": "CC BY 4.0",
  "licenseUrl": "https://creativecommons.org/licenses/by/4.0/",
  "licenseNote": "CC BY 4.0 covers the author's own text and classification fields. Quoted Microsoft error wording belongs to Microsoft, is quoted with its source and can change.",
  "attribution": "Bram Brinkmeier, \"AADSTS9002326: Cross-origin token request from a non-SPA app (fix)\", https://brambrinkmeier.com/entra/aadsts/9002326/ (CC BY 4.0)",
  "microsoftErrorLookup": "https://login.microsoftonline.com/error?code=9002326"
}
