This document outlines the steps to fix the "invalid signature" error you're encountering due to token version mismatches between your SPA and API application registrations.
Based on the token information provided, we've identified these issues:
- Your access token has
ver: "1.0"but your API is likely configured for v2.0 tokens - The audience (
aud) in your access token is Microsoft Graph (00000003-0000-0000-c000-000000000000) instead of your API - The scope in your token is for Microsoft Graph (
User.Read), not for your custom API scope (ToolBox.API)
Your API app registration needs to be explicitly configured to accept v2.0 tokens:
-
Navigate to your API app registration (ToolBox.API) in Entra portal
- Go to Azure Portal → Microsoft Entra ID → App Registrations → Your API App
- Click on "Manifest" in the left navigation
-
Update the manifest to explicitly set token version:
Look for or add the following setting:
"accessTokenAcceptedVersion": 2
Make sure it's set to
2(notnullor1). -
Verify Application ID URI:
Check that the
identifierUrissection contains your API's identifier URI:"identifierUris": [ "api://your-api-client-id" ]
-
Save the manifest changes
The issue in your frontend is that it's requesting tokens for Microsoft Graph, not for your API.
- Update the authConfig.js in your SPA:
// Keep the login request focused on user sign-in
const loginRequest = {
scopes: ["User.Read"] // Only request Microsoft Graph scopes for login
};
// Create a separate request object specifically for your API
const apiRequest = {
scopes: ["api://your-api-client-id/Greeting.Read"] // Your API scope
};- Update your apiClient.js to use the correct scope request:
// API configuration
const apiConfig = {
uri: 'http://localhost:8080',
// Make sure this matches EXACTLY with what's configured in your API app registration
scopes: ['api://your-api-client-id/Greeting.Read']
};
/**
* Makes an authenticated API call
*/
async function callApi() {
const account = myMSALObj.getActiveAccount();
if (!account) {
throw Error("No active account! Verify a user has been signed in and setActiveAccount has been called.");
}
// Create a request specifically for the API scope
const request = {
scopes: apiConfig.scopes,
account: account
};
// Silently acquires an access token which is then attached to a request
return myMSALObj.acquireTokenSilent(request)
.then((response) => {
// Log the token for debugging (remove in production)
console.log("Token type:", response.tokenType);
console.log("For scopes:", response.scopes);
const headers = new Headers();
headers.append("Authorization", `Bearer ${response.accessToken}`);
const options = {
method: "GET",
headers: headers
};
return fetch(apiConfig.uri, options)
.then(response => response.text())
.catch(error => {
console.error('API call failed:', error);
throw error;
});
}).catch((error) => {
console.error('Token acquisition failed:', error);
if (error instanceof msal.InteractionRequiredAuthError) {
// Fallback to interactive method when silent call fails
return myMSALObj.acquireTokenRedirect(request);
}
throw error;
});
}Ensure your API is properly configured to validate v2.0 tokens:
const config = {
auth: {
// Your tenant ID
tenant: 'your-tenant-id-here',
// The Application (client) ID of your API application
audience: 'your-api-client-id-here'
}
}
// Add proper CORS handling
const express = require('express')
const cors = require('cors');
const {expressjwt: jwt } = require("express-jwt");
const jwks = require('jwks-rsa')
const jwtAuthz = require('express-jwt-authz')
// Initialize Express
const app = express()
// Add CORS middleware - critical for browser-based API calls
app.use(cors({
origin: 'http://localhost:3000', // Your SPA URL
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// JWT validation middleware - make sure it's configured for v2.0 tokens
app.use(jwt({
secret: jwks.expressJwtSecret({
jwksUri: 'https://login.microsoftonline.com/' + config.auth.tenant + '/discovery/v2.0/keys'
}),
audience: config.auth.audience, // This should match your API client ID
issuer: 'https://login.microsoftonline.com/' + config.auth.tenant + '/v2.0', // v2.0 endpoint
algorithms: ['RS256']
}));After making these changes:
-
Get a new token by logging in to your SPA
-
Check the token using jwt.ms and verify:
- It has
ver: "2.0"(token version) - The audience (
aud) is your API's client ID, not Microsoft Graph - It contains your API's scope (
Greeting.Read)
- It has
-
Debug API calls:
- In the browser dev tools, inspect network requests when calling your API
- Verify the Authorization header contains the token
- Check server logs for any validation errors
- Cause: Token was signed with a key from v1.0 endpoint but being validated against v2.0 keys (or vice versa)
- Solution: Ensure
accessTokenAcceptedVersionis consistently set to 2 and both client and API use v2.0 endpoints
- Cause: Token was issued for Microsoft Graph but being used for your API
- Solution: Ensure your SPA is requesting a token specifically for your API by using the correct scope format
- Cause: Token doesn't contain the required scope (Greeting.Read)
- Solution: Make sure your SPA is requesting the correct scope and you've granted API permissions in the app registration
This documentation explains how to configure and integrate the vanilla JavaScript single-page application (SPA) with the protected Node.js API using Microsoft Entra ID (formerly Azure AD) for authentication and authorization.
This setup involves:
- Frontend: Vanilla JavaScript SPA that authenticates users using MSAL.js
- Backend: Node.js Express API that validates JWT tokens and enforces scope-based access control
- Authentication Provider: Microsoft Entra ID with OAuth 2.0 / OpenID Connect
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Vanilla JS │ │ Entra ID │ │ Protected │
│ SPA Client │◄──►│ Authority │ │ Node.js API │
│ (Frontend) │ │ │ │ (Backend) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ ▲
│ Access Token with Scopes │
└──────────────────────────────────────────────┘
- Node.js (version 16 or higher)
- Microsoft Entra tenant with administrative permissions
- Visual Studio Code (recommended)
-
Navigate to the Microsoft Entra admin center
- Go to https://entra.microsoft.com
- Sign in with an account that has permissions to manage app registrations
-
Create the API App Registration
- Navigate to Applications > App registrations
- Click New registration
- Configure the following:
- Name:
ToolBox.API(or your preferred API name) - Supported account types: Accounts in this organizational directory only (Single tenant)
- Redirect URI: Leave blank (not needed for API)
- Name:
- Click Register
-
Note the Application Details
- Copy the Application (client) ID - you'll need this for the API configuration
- Copy the Directory (tenant) ID - you'll need this for both frontend and API
-
Expose an API Scope
- In your API app registration, go to Expose an API
- Click Set next to Application ID URI and accept the default (
api://{client-id}) - Click Add a scope and configure:
- Scope name:
Greeting.Read - Who can consent?: Admins and users
- Admin consent display name:
Read API Greetings - Admin consent description:
Allows the user to see greetings from the API. - User consent display name:
Read API Greetings - User consent description:
Allows you to see greetings from the API. - State: Enabled
- Scope name:
- Click Add scope
-
Create the SPA App Registration
- Still in App registrations, click New registration
- Configure the following:
- Name:
ToolBox.SPA(or your preferred SPA name) - Supported account types: Accounts in this organizational directory only (Single tenant)
- Redirect URI:
- Platform: Single-page application (SPA)
- URI:
http://localhost:3000
- Name:
- Click Register
-
Note the Application Details
- Copy the Application (client) ID - you'll need this for the SPA configuration
-
Configure API Permissions
- In your SPA app registration, go to API permissions
- Click Add a permission
- Select My APIs tab
- Choose your ToolBox.API application
- Select Delegated permissions
- Check the Greeting.Read scope you created earlier
- Click Add permissions
- (Optional) Click Grant admin consent if you want to pre-consent for all users
-
Configure Authentication Settings
- Go to Authentication
- Under Single-page application, ensure
http://localhost:3000is listed - Add additional redirect URIs if needed:
http://localhost:3000/redirecthttp://localhost:3000/(with trailing slash)
- Under Advanced settings:
- Allow public client flows: No
- Live SDK support: No
- Save the configuration
-
Navigate to the protect-api folder
cd protect-api -
Install dependencies
npm install
-
Update the configuration in
app.jsReplace the placeholder values in the config object:
const config = { auth: { // Replace with your tenant ID from Part 1.1 step 3 tenant: 'your-tenant-id-here', // Replace with your API's Application (client) ID from Part 1.1 step 3 audience: 'your-api-client-id-here' } }
-
Start the API server
node app.js
-
Verify the API is running
- You should see:
Listening here: http://localhost:8080 - The API endpoint is now protected and requires a valid JWT token with the
Greeting.Readscope
- You should see:
-
Navigate to the vanillajs-spa/App folder
cd ../vanillajs-spa/App -
Install dependencies
npm install
-
Update
public/authConfig.jsReplace the placeholder values:
const msalConfig = { auth: { // Replace with your SPA's Application (client) ID from Part 1.2 step 2 clientId: "your-spa-client-id-here", // Replace with your tenant ID from Part 1.1 step 3 authority: "https://login.microsoftonline.com/your-tenant-id-here", redirectUri: 'http://localhost:3000', // Must match registered redirect URI navigateToLoginRequestUrl: true, }, cache: { cacheLocation: 'sessionStorage', storeAuthStateInCookie: false, }, // ... rest of configuration remains the same }; // Update the login request to include your API scope const loginRequest = { scopes: ["User.Read", "api://your-api-client-id-here/Greeting.Read"], };
-
Create a new file
public/apiClient.jsto handle API calls:// API configuration const apiConfig = { uri: 'http://localhost:8080', scopes: ['api://your-api-client-id-here/Greeting.Read'] // Replace with your API client ID }; /** * Makes an authenticated API call */ async function callApi() { const account = myMSALObj.getActiveAccount(); if (!account) { throw Error("No active account! Verify a user has been signed in and setActiveAccount has been called."); } const request = { scopes: apiConfig.scopes, account: account }; // Silently acquires an access token which is then attached to a request for the Microsoft Graph API return myMSALObj.acquireTokenSilent(request) .then((response) => { const headers = new Headers(); headers.append("Authorization", `Bearer ${response.accessToken}`); const options = { method: "GET", headers: headers }; return fetch(apiConfig.uri, options) .then(response => response.text()) .catch(error => { console.error('API call failed:', error); throw error; }); }).catch((error) => { console.error('Token acquisition failed:', error); if (error instanceof msal.InteractionRequiredAuthError) { // Fallback to interactive method when silent call fails return myMSALObj.acquireTokenRedirect(request); } throw error; }); }
-
Update
public/index.htmlto include the new API client and add a button to test the API:Add this script tag after the existing script includes:
<script type="text/javascript" src="./apiClient.js"></script>
Add this button in the welcome section:
<button type="button" id="callApiBtn" class="btn btn-success ml-auto" onclick="testApi()">Call Protected API</button> <div id="api-response" class="mt-3"></div>
-
Update
public/ui.jsto add the API test function:Add this function to handle the API call:
async function testApi() { try { const responseDiv = document.getElementById('api-response'); responseDiv.innerHTML = '<p>Calling API...</p>'; const apiResponse = await callApi(); responseDiv.innerHTML = `<div class="alert alert-success"><strong>API Response:</strong><br>${apiResponse}</div>`; } catch (error) { console.error('API call failed:', error); const responseDiv = document.getElementById('api-response'); responseDiv.innerHTML = `<div class="alert alert-danger"><strong>API Error:</strong><br>${error.message}</div>`; } } // Show/hide API button based on sign-in status function updateUI(account) { const callApiBtn = document.getElementById('callApiBtn'); if (account) { callApiBtn.style.display = 'block'; } else { callApiBtn.style.display = 'none'; } }
-
Start the development server
npm start
-
Open your browser
- Navigate to
http://localhost:3000
- Navigate to
-
Start both applications:
- API:
http://localhost:8080(should be running from Part 2.2) - SPA:
http://localhost:3000(should be running from Part 3.3)
- API:
-
Test the authentication flow:
- Open
http://localhost:3000in your browser - Click Sign In
- Complete the authentication with your Entra ID account
- You should see a welcome message with your user information
- Open
-
Test the API integration:
- After signing in, click Call Protected API
- You should see a success message from the API: "Hello, world. You were able to access this because you provided a valid access token with the Greeting.Read scope as a claim."
Solution: Add CORS middleware to your API:
cd protect-api
npm install corsUpdate app.js to include CORS:
const cors = require('cors');
// Add after initializing Express
app.use(cors({
origin: 'http://localhost:3000' // Your SPA URL
}));Solution: Verify that:
- The
audiencein the API config matches your API's Application (client) ID - The scope in the SPA includes your API's Application ID URI
Solution: Ensure:
- The scope
Greeting.Readis properly exposed in your API app registration - The SPA app registration has permissions to access this scope
- The scope format in the SPA is
api://your-api-client-id/Greeting.Read
Solution: Verify:
- Redirect URIs are properly registered in the SPA app registration
- URIs match exactly (including trailing slashes)
- The
redirectUriinauthConfig.jsmatches a registered URI
-
Use HTTPS in production
- Update redirect URIs to use HTTPS
- Ensure API endpoints use HTTPS
- Use secure cookie settings
-
Token validation
- The API already validates JWT signatures and expiration
- Consider implementing additional claims validation as needed
-
CORS configuration
- Restrict CORS origins to your specific domains
- Don't use wildcards in production
-
Use environment variables for sensitive data:
API configuration (
protect-api/.env):TENANT_ID=your-tenant-id CLIENT_ID=your-api-client-idSPA configuration (use build-time replacement):
const msalConfig = { auth: { clientId: process.env.REACT_APP_CLIENT_ID, authority: `https://login.microsoftonline.com/${process.env.REACT_APP_TENANT_ID}`, // ... } };
-
API Deployment:
- Deploy to Azure App Service, Azure Container Instances, or your preferred hosting platform
- Update CORS settings to include your production SPA URL
- Ensure environment variables are properly configured
-
SPA Deployment:
- Build the application for production
- Deploy static files to Azure Static Web Apps, CDN, or web server
- Update app registration redirect URIs to include production URLs
You now have a complete setup with:
-
Two Entra ID app registrations:
- Backend API (
ToolBox.API) with exposed scopeGreeting.Read - Frontend SPA (
ToolBox.SPA) with delegated permissions to the API
- Backend API (
-
Protected Node.js API that:
- Validates JWT tokens from Entra ID
- Enforces scope-based authorization
- Returns data only to authenticated users with the correct scope
-
Vanilla JavaScript SPA that:
- Authenticates users with Entra ID using MSAL.js
- Acquires access tokens with the appropriate scopes
- Makes authenticated API calls to the protected backend
The integration provides secure, token-based authentication and authorization using industry-standard OAuth 2.0 and OpenID Connect protocols through Microsoft Entra ID.