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:
- Your SPA generates a random code verifier
- Creates a code challenge from the verifier
- Sends the challenge during authentication
- 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
API Security
SPA Security
Monitoring
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:
- Properly configuring redirect URIs in Azure AD
- Validating tokens rigorously on your backend API
- Using secure token storage (in-memory, not localStorage)
- Implementing proper CORS policies
- 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!
Like this:
Like Loading...
You must be logged in to post a comment.