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:
- User signs in using MSAL (Microsoft Authentication Library).
- The browser receives an access token for Dataverse.
- The SPA calls Dataverse Web API with
Authorization: Bearer <token>. - Dataverse validates the token and enforces the signed-in user’s security roles.
- 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:3000https://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-inacquireTokenSilent()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
