Best Practices: Securing Azure AD Configuration in Single Page Applications

Reading Time: 10 minutes

When building Single Page Applications (SPAs) with Azure AD authentication, developers often ask: “How do I protect my Tenant ID and Client ID from being exposed?”

The short answer might surprise you: You don’t—and you shouldn’t try to.

This article explains what information is truly public vs. secret in SPAs, debunks common security misconceptions, and provides practical best practices for securing your Azure AD-enabled applications.

Understanding What’s Secret and What’s Not

First, let’s clarify a critical distinction that causes much confusion:

Public Values (NOT Secrets)

These values cannot and should not be kept secret in SPAs:

  • Client ID (Application ID) – Identifies your application
  • Tenant ID – Identifies your Azure AD tenant
  • Authority URLs – The login endpoints
  • Redirect URIs – Where authentication flows return
  • API Scopes – Permissions your app requests

Why? Microsoft designs these values to be public. Any code running in a browser can be inspected by users—there are no secrets in client-side code.

Actual Secrets (NEVER in SPAs)

These must never appear in client-side code:

  • Client Secrets – Use server-side applications only
  • API Keys – Backend only
  • Connection Strings – Backend only
  • Private Keys – Backend only

Why? If these appear in your SPA, any user can extract and abuse them.

The PKCE Flow: Security Without Secrets

SPAs use the Proof Key for Code Exchange (PKCE) OAuth 2.0 flow, which is specifically designed to work securely without client secrets.

How PKCE Works:

  1. Your SPA generates a random code verifier
  2. Creates a code challenge from the verifier
  3. Sends the challenge during authentication
  4. Azure AD validates using the verifier

This cryptographic exchange prevents token interception attacks without requiring secrets.

Example: MSAL.js Configuration

// This is perfectly secure - no secrets needed
const msalConfig = {
    auth: {
        clientId: "1F5125A6-0098-493D-9C4E-2CA6DDD11998",  // Public
        authority: "https://login.microsoftonline.com/A588175B-C520-4961-BF1F-2C583DD047C8",  // Public
        redirectUri: "http://localhost:5173"  // Public, but controlled in Azure AD
    }
};

// MSAL.js automatically uses PKCE flow
const msalInstance = new msal.PublicClientApplication(msalConfig);

Best Practice #1: Register Allowed Redirect URIs

While Client IDs are public, you control where authentication tokens can be sent through Azure AD app registration.

Configuration in Azure Portal

Navigate to: Azure Portal → App Registrations → Your App → Authentication

Redirect URIs:

Key Principle: Even if someone steals your Client ID, they cannot receive tokens unless they control a registered redirect URI. This is your primary defense mechanism.

Best Practices for Redirect URIs:

  • Only register legitimate URIs you control
  • Use HTTPS in production (never HTTP)
  • Never use wildcards (e.g., https://*.yourdomain.com)
  • Avoid overly permissive patterns

Best Practice #2: Validate Tokens on the Backend

The real security happens on your API server, not in the SPA. Always validate every token.

ASP.NET Core API Token Validation

// Program.cs
builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", options => 
    {
        var tenantId = builder.Configuration["AzureAd:TenantId"];
        var apiClientId = builder.Configuration["AzureAd:ApiClientId"];

        options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
        
        // Critical validation settings
        options.TokenValidationParameters = new()
        {
            ValidAudience = $"api://{apiClientId}",
            ValidateIssuer = true,      // Verify token from correct tenant
            ValidateAudience = true,    // Verify token for this API
            ValidateLifetime = true,    // Reject expired tokens
            ValidateIssuerSigningKey = true  // Verify signature
        };
    });

// Apply authorization to endpoints
app.MapGet("/api/secure-data", () => "Sensitive data")
    .RequireAuthorization("RequiredPolicy");

Security Checklist:

  • Always validate issuer (prevents tokens from other tenants)
  • Always validate audience (prevents tokens for other APIs)
  • Always validate lifetime (rejects expired tokens)
  • Always validate signature (prevents tampering)
  • Use claims and scopes for authorization

Best Practice #3: Secure Token Storage

Where you store tokens matters significantly for security.

✅ Good: In-Memory Storage (MSAL.js Default)

// MSAL.js stores tokens in memory by default
const msalInstance = new msal.PublicClientApplication(msalConfig);

// Tokens are automatically managed and stored securely
const token = await msalInstance.acquireTokenSilent({
    scopes: ["api://your-api/cms.read"]
});

❌ Bad: localStorage or sessionStorage

// NEVER DO THIS - Vulnerable to XSS attacks
localStorage.setItem('accessToken', token.accessToken);  // ❌ BAD
sessionStorage.setItem('accessToken', token.accessToken);  // ❌ ALSO BAD

Why localStorage is Dangerous:

  • Accessible to any JavaScript on your domain
  • Vulnerable to XSS (Cross-Site Scripting) attacks
  • Persists across browser sessions
  • No built-in security features

Best Practice #4: Configure CORS Properly

Control which origins can call your API to prevent unauthorized cross-origin requests.

ASP.NET Core CORS Configuration

// Program.cs - Configure CORS restrictively
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", policy =>
    {
        policy.WithOrigins("https://yourdomain.com")  // Specific origins only
              .AllowAnyHeader()
              .AllowAnyMethod()
              .AllowCredentials();  // Required for authentication
    });
});

// Apply CORS middleware
app.UseCors("AllowFrontend");

Important:

  • Never use AllowAnyOrigin() in production
  • Explicitly list allowed origins
  • Use environment-specific configurations

Configuration Management Strategies

While Client IDs can be hardcoded without security risk, proper configuration management improves maintainability.

Option 1: Hardcoded Values (Acceptable)

// index.html - Perfectly acceptable for public values
const clientId = "1F5125A6-0098-493D-9C4E-2CA6DDD11998";
const tenantId = "A588175B-C520-4961-BF1F-2C583DD047C8";

Pros: Simple, fast, no dependencies
Cons: Requires rebuild for environment changes

Option 2: Environment Variables (Better for Multi-Environment)

// Use build-time environment variables (Vite example)
const clientId = import.meta.env.VITE_CLIENT_ID;
const tenantId = import.meta.env.VITE_TENANT_ID;

// .env.production
VITE_CLIENT_ID=1F5125A6-0098-493D-9C4E-2CA6DDD11998
VITE_TENANT_ID=A588175B-C520-4961-BF1F-2C583DD047C8

Pros: Environment-specific, industry standard
Cons: Still requires rebuild per environment

Option 3: Configuration Endpoint (Best for Centralization)

Serve configuration from your backend API for runtime flexibility.

Backend Configuration Endpoint (.NET)

// Program.cs
app.MapGet("/config", (IConfiguration config) => Results.Ok(new
{
    azureAd = new
    {
        clientId = config["AzureAd:ClientId"],
        tenantId = config["AzureAd:TenantId"],
        apiClientId = config["AzureAd:ApiClientId"]
    }
}));

appsettings.json

{
  "AzureAd": {
    "ClientId": "1F5125A6-0098-493D-9C4E-2CA6DDD11998",
    "TenantId": "A588175B-C520-4961-BF1F-2C583DD047C8",
    "ApiClientId": "ABDBD38F-384E-4640-B7C6-34C8340A442E"
  }
}

Frontend Initialization

// index.html - Fetch configuration at runtime
let msalInstance;
let apiScope;

(async function initializeApp() {
    try {
        // Fetch configuration from API
        const response = await fetch('https://api.yourdomain.com/config');
        const config = await response.json();

        // Initialize MSAL with fetched configuration
        const msalConfig = {
            auth: {
                clientId: config.azureAd.clientId,
                authority: `https://login.microsoftonline.com/${config.azureAd.tenantId}`,
                redirectUri: window.location.origin
            }
        };

        apiScope = `api://${config.azureAd.apiClientId}/cms.read`;
        msalInstance = new msal.PublicClientApplication(msalConfig);

        console.log('App initialized successfully');
    } catch (error) {
        console.error('Failed to load configuration:', error);
    }
})();

Pros: Single source of truth, no rebuilds needed, runtime flexibility
Cons: Additional HTTP request on startup

Additional Security Measures

1. Implement Content Security Policy (CSP)

<!-- Add to HTML head or HTTP headers -->
<meta http-equiv="Content-Security-Policy" 
      content="default-src 'self'; 
               script-src 'self' https://alcdn.msauth.net; 
               connect-src 'self' https://login.microsoftonline.com">

2. Enable Azure AD Conditional Access

Configure in Azure Portal to enforce:

  • Multi-factor authentication (MFA)
  • Trusted device requirements
  • IP address restrictions
  • Risk-based access policies

3. Use Short-Lived Tokens

Azure AD defaults to 1-hour access token lifetimes. Don’t increase this unnecessarily.

// MSAL.js handles token refresh automatically
const token = await msalInstance.acquireTokenSilent({
    scopes: [apiScope],
    account: msalInstance.getActiveAccount()
});

4. Implement API Rate Limiting

// ASP.NET Core rate limiting
builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("api", opt =>
    {
        opt.Window = TimeSpan.FromMinutes(1);
        opt.PermitLimit = 100;
    });
});

app.UseRateLimiter();

app.MapGet("/api/data", () => "data")
   .RequireRateLimiting("api");

5. Monitor and Audit

  • Enable Azure AD sign-in logs
  • Monitor failed authentication attempts
  • Set up alerts for suspicious activity
  • Review API access patterns regularly

Common Security Anti-Patterns to Avoid

❌ Anti-Pattern 1: Trying to Hide Public Values

// Don't waste time obfuscating public values
const secret = atob("ZjFmYWY2NGYtMWE2NC00NWNjLTlkNGItZTIwMDhkMWNhZmM5");  // ❌ Pointless

Anyone can decode this. Accept that Client IDs are public.

❌ Anti-Pattern 2: Proxying Authentication Through Your Backend

// DON'T do this to "hide" client ID
app.post('/auth/login', (req, res) => {
    // Server exchanges credentials for token
    // Then sends token to client
});  // ❌ Defeats PKCE security, adds complexity

Use proper OAuth 2.0 flows designed for SPAs.

❌ Anti-Pattern 3: Client Secrets in SPAs

// NEVER include client secrets
const msalConfig = {
    auth: {
        clientId: "...",
        clientSecret: "super-secret-key"  // ❌ EXTREMELY DANGEROUS
    }
};  // Anyone can extract this and impersonate your app

Security Checklist for Production

Before deploying your SPA to production, verify:

Azure AD Configuration

  • Redirect URIs are restrictively configured
  • No wildcards in redirect URIs
  • HTTPS enforced for production URIs
  • Appropriate API permissions configured
  • Admin consent granted (if required)

API Security

  • Token validation enabled (issuer, audience, lifetime)
  • Authorization policies implemented
  • CORS properly configured
  • Rate limiting implemented
  • HTTPS enforced

SPA Security

  • No client secrets in code
  • Tokens stored in memory (not localStorage)
  • Content Security Policy implemented
  • Dependencies updated and scanned
  • Proper error handling (no token leaks in errors)

Monitoring

  • Sign-in logs enabled
  • Failed authentication alerts configured
  • API access logging implemented
  • Regular security reviews scheduled

Conclusion

Securing SPAs with Azure AD doesn’t require hiding Client IDs or Tenant IDs—these values are designed to be public. Instead, focus your security efforts on:

  1. Properly configuring redirect URIs in Azure AD
  2. Validating tokens rigorously on your backend API
  3. Using secure token storage (in-memory, not localStorage)
  4. Implementing proper CORS policies
  5. Enabling monitoring and alerts for suspicious activity

The security of your SPA relies on proper OAuth 2.0 flows (PKCE), token validation, and infrastructure configuration—not on attempting to hide public identifiers.

Remember: The PKCE flow is specifically designed to work securely with public Client IDs. Trust the design, follow best practices, and focus on the security measures that truly matter.

Additional Resources


Have questions or feedback? Share your thoughts in the comments below!

Leave a Reply