Connecting to Dataverse from a Single Page App (SPA) using MSAL + CORS

In the previous post, we covered multi-tenant Server-to-Server (S2S) authentication the pattern for backend SaaS platforms. Now we switch to the opposite extreme:

A browser-only app (React / Angular / Vue) that calls Dataverse directly.

No backend proxy.
No middleware.
Just JavaScript, OAuth tokens, and the Dataverse Web API.

This is the SPA + CORS pattern.


When should you use SPA + CORS?

Use this approach when:

  • You’re building a lightweight internal portal or dashboard
  • You want direct access to Dataverse from the browser
  • You can rely on user sign-in (delegated access)
  • You don’t want to maintain a backend just to relay API calls

Avoid this approach when:

  • You need app-only access (application permissions)
  • You need to hide secrets (SPAs can’t store secrets safely)
  • You need heavy data processing, aggregation, or strict governance
  • You want a strong API boundary (in that case, build a backend)

Key rule:

SPAs can only use delegated user authentication.


How it works (high-level)

The flow looks like this:

  1. User signs in using MSAL (Microsoft Authentication Library).
  2. The browser receives an access token for Dataverse.
  3. The SPA calls Dataverse Web API with Authorization: Bearer <token>.
  4. Dataverse validates the token and enforces the signed-in user’s security roles.
  5. CORS settings determine whether the request is allowed from the SPA domain.

That’s it. This is modern OAuth in its purest form.


Step 1 – Register an app (Entra ID) for a SPA

Create an App Registration in Microsoft Entra ID.

Recommended settings:

Authentication

  • Platform type: Single-page application
  • Redirect URI examples:
    • http://localhost:3000
    • https://yourapp.yourdomain.com

API permissions

Add delegated permission to Dynamics CRM:

  • user_impersonation

Supported account types

  • Single tenant for internal apps
  • Multi-tenant only if you genuinely need it (adds complexity)

Step 2 – Configure Dataverse CORS

Dataverse must allow cross-origin calls from your SPA domain.

You typically allow:

  • http://localhost:3000 (local dev)
  • https://yourapp.yourdomain.com (production)

If CORS is not configured correctly, you’ll see browser errors like:

  • “CORS policy blocked…”
  • 401/403 failures even when token is valid
  • Preflight (OPTIONS) failures

This is not a Dataverse authentication issue — it’s a browser security boundary issue.


Step 3 – Install MSAL

npm install @azure/msal-browser

Step 4 – MSAL Configuration (SPA)

Create a file authConfig.js (or .ts).

import { PublicClientApplication } from "@azure/msal-browser";

export const msalConfig = {
  auth: {
    clientId: "YOUR-APP-ID",
    authority: "https://login.microsoftonline.com/YOUR-TENANT-ID",
    redirectUri: window.location.origin
  },
  cache: {
    cacheLocation: "sessionStorage", // better than localStorage for security
    storeAuthStateInCookie: false
  }
};

export const msalInstance = new PublicClientApplication(msalConfig);

Why sessionStorage?

  • Reduces persistence risk
  • Limits exposure compared to long-lived local storage

Step 5 – Acquire a token for Dataverse

This is the most important detail that people get wrong:

Dataverse tokens use a scope based on your environment URL.

Example scope:

https://yourorg.crm.dynamics.com/user_impersonation

Here’s the code:

import { msalInstance } from "./authConfig";

const dataverseUrl = "https://yourorg.crm.dynamics.com";
const dataverseScope = `${dataverseUrl}/user_impersonation`;

export async function getAccessToken() {
  const accounts = msalInstance.getAllAccounts();

  if (accounts.length === 0) {
    await msalInstance.loginPopup({ scopes: [dataverseScope] });
  }

  const account = msalInstance.getAllAccounts()[0];

  const result = await msalInstance.acquireTokenSilent({
    account,
    scopes: [dataverseScope]
  });

  return result.accessToken;
}

What’s happening:

  • loginPopup() performs interactive sign-in
  • acquireTokenSilent() reuses session and refreshes tokens silently

Step 6 – Call Dataverse Web API

Now we use the token to call Web API (OData).

Example: fetch top 5 accounts.

import { getAccessToken } from "./token";

const dataverseUrl = "https://yourorg.crm.dynamics.com";

export async function getAccounts() {
  const token = await getAccessToken();

  const response = await fetch(
    `${dataverseUrl}/api/data/v9.2/accounts?$select=name,accountid&$top=5`,
    {
      method: "GET",
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: "application/json",
        "OData-MaxVersion": "4.0",
        "OData-Version": "4.0"
      }
    }
  );

  if (!response.ok) {
    const text = await response.text();
    throw new Error(text);
  }

  const data = await response.json();
  return data.value;
}

This is direct Dataverse Web API access from the browser.

No SDK.
No server.
No proxy.


Step 7 – Create / Update example (POST)

Create a new Account:

export async function createAccount(name) {
  const token = await getAccessToken();

  const response = await fetch(
    `${dataverseUrl}/api/data/v9.2/accounts`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: "application/json",
        "Content-Type": "application/json",
        "OData-MaxVersion": "4.0",
        "OData-Version": "4.0"
      },
      body: JSON.stringify({ name })
    }
  );

  if (!response.ok) throw new Error(await response.text());

  // Dataverse returns entity URI in OData-EntityId header
  return response.headers.get("OData-EntityId");
}

Security model: what permissions matter?

Dataverse will enforce the user’s security role. If the user cannot read Accounts in the UI, they can’t read Accounts in the SPA. This is extremely important:

The SPA doesn’t bypass Dataverse security.

But it does bypass your ability to create a backend boundary.

So the real control points become:

  • Dataverse security roles
  • Field-level security
  • API access governance
  • CORS origin controls

Leave a comment