Implement a secure web application using Vue.js and an ASP.NET Core server

Most teams building a single page application end up asking the same question sooner or later: where do the tokens live? Keep them in the browser and you are exposed to XSS. Move the OpenID Connect handshake into the server and the browser never sees a token at all. That second approach is called the Backend for Frontend pattern, or BFF, and this walkthrough shows it applied to a Vue.js front end hosted inside an ASP.NET Core server, authenticating against Microsoft Entra ID.

Vue.js BFF samples are genuinely rare. Most public examples use Angular or React, so if your team has standardized on Vue, this is one of the few references that maps the pattern onto it directly, including the CSP nonce handling that Vue’s Vite build makes slightly fiddly.

What the BFF Pattern Actually Buys You

In this setup, the OpenID Connect confidential client lives entirely on the server. It authenticates using the authorization code flow with PKCE, backed by a client secret in development and a certificate in production, which is a sensible split since secrets in app settings are a recurring audit finding. After a successful login, the server issues an HTTP only, secure cookie to track the session. No token, access or id, ever reaches client side JavaScript.

This matters because it removes an entire class of vulnerability. If an attacker manages to inject a script into your Vue app through a dependency or a reflected XSS bug, there is no token sitting in memory or local storage for that script to steal. The cookie is HTTP only, so document.cookie cannot read it either. You still need to defend against CSRF, since cookies are sent automatically by the browser, but that is a well understood problem with a well understood fix, covered further down.

High level architecture: Vue.js SPA served from the same origin as the ASP.NET Core BFF, with the identity provider and downstream API sitting behind the server
High level architecture: Vue.js SPA served from the same origin as the ASP.NET Core BFF, with the identity provider and downstream API sitting behind the server

The diagram shows the single deployable unit. The Vue.js production build gets copied into the ASP.NET Core wwwroot folder at build time, so in production there is exactly one process, one origin, and one set of security headers to manage. That single origin detail is worth calling out because it also sidesteps CORS configuration entirely for the browser to server leg. CORS only becomes relevant if the server itself calls a separate downstream API from a different origin, and that call happens server side, not from the browser.

Setting Up the Vue.js Project for Local HTTPS

Local development should mirror production as closely as possible, so this setup runs the Vite dev server over HTTPS from day one rather than deferring TLS to a later stage. Catching CSP and cookie SameSite issues in development is far cheaper than catching them after a deployment.

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import fs from 'fs';
 
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  server: {
    https: {
      key: fs.readFileSync('./certs/dev_localhost.key'),
      cert: fs.readFileSync('./certs/dev_localhost.pem'),
    },
    port: 4202,
    strictPort: true, // exit if port is in use
    hmr: {
      clientPort: 4202,
    },
  },
  optimizeDeps: {
    force: true,
  },
  build: {
    outDir: "../server/wwwroot",
    emptyOutDir: true
  },
})

The build.outDir setting is the piece that ties the two stacks together. Instead of writing the production build to a dist folder that you then have to script into place, Vite drops it straight into the ASP.NET Core project’s wwwroot. A dev certificate is also loaded explicitly here, since Vite does not trust the ASP.NET Core dev cert by default, and you will need to generate one locally (dotnet dev-certs or mkcert both work fine) and point key and cert at the right files.

Adding a Content Security Policy With Nonces

A strict CSP is one of the better defenses against XSS, but it only works if inline scripts and styles carry a nonce that changes on every response. Vue’s build emits a script tag without any hook for a server generated nonce, so the index.html needs a placeholder that gets swapped out at request time.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="CSP_NONCE" content="**PLACEHOLDER_NONCE_SERVER**" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue + TS</title>
  </head>
  <body>
    <div id="app"></div>
    /src/main.ts
  </body>
</html>

That PLACEHOLDER_NONCE_SERVER string is just a marker. The ASP.NET Core Razor page that actually serves this file reads it, generates a real nonce for the current request, and does a string replace before sending the HTML down the wire.

@page "/"
@namespace BlazorBffAzureAD.Pages
@using System.Net;
@using NetEscapades.AspNetCore.SecurityHeaders;
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, NetEscapades.AspNetCore.SecurityHeaders.TagHelpers
@inject IHostEnvironment hostEnvironment
@inject IConfiguration config
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery antiForgery
@{
    Layout = null;
 
    var source = "";
    if (hostEnvironment.IsDevelopment())
    {
        var httpClient = new HttpClient();
        source = await httpClient.GetStringAsync($"{config["UiDevServerUrl"]}/index.html");
    }
    else
    {
        source = System.IO.File.ReadAllText($"{System.IO.Directory.GetCurrentDirectory()}{@"/wwwroot/index.html"}");
    }
 
    var nonce = HttpContext.GetNonce();
 
    // The nonce is passed to the client through the HTML to avoid sync issues between tabs
    source = source.Replace("**PLACEHOLDER_NONCE_SERVER**", nonce);
 
    var nonceScript = $"<script nonce=\"{nonce}\" type=";
    source = source.Replace("<script type=", nonceScript);
 
    // link rel="stylesheet"
    var nonceLinkStyle = $"<link nonce=\"{nonce}\" rel=\"stylesheet";
    source = source.Replace("<link rel=\"stylesheet", nonceLinkStyle);
 
    var xsrf = antiForgery.GetAndStoreTokens(HttpContext);
    var requestToken = xsrf.RequestToken;
 
    // The XSRF-Tokens are passed to the client through cookies, since we always want the most up-to-date cookies across all tabs
    Response.Cookies.Append("XSRF-RequestToken", requestToken ?? "", new CookieOptions() { HttpOnly = false, IsEssential = true, Secure = true, SameSite = SameSiteMode.Strict });
}
 
@Html.Raw(source)

Two different things happen in this handler depending on environment. In development, the page fetches the raw index.html straight from the running Vite dev server, since that is where the actual file lives while you are iterating. In production, it reads the built file from wwwroot instead. Either way, string replacement injects the nonce into the meta tag, the script tag, and any stylesheet link tag, and the same request also mints a fresh anti-forgery token and drops it into a readable cookie.

Protecting API Calls With Anti-Forgery Tokens

Same-Site cookies handle most CSRF protection in modern browsers, but you cannot assume every user is on a current browser, and defense in depth is cheap here. The XSRF-RequestToken cookie set above is deliberately not HTTP only, because the Vue app needs to read it and echo it back as a header on every state-changing API call.

export const getCookie = (cookieName: string) => {
  const name = `${cookieName}=`;
  const decodedCookie = decodeURIComponent(document.cookie);
  const ca = decodedCookie.split(";");
  for (let i = 0; i < ca.length; i += 1) {
    let c = ca[i];
    while (c.charAt(0) === " ") {
      c = c.substring(1);
    }
    if (c.indexOf(name) === 0) {
      return c.substring(name.length, c.length);
    }
  }
  return "";
};

This small helper just parses document.cookie to pull out the XSRF-RequestToken value. It gets called wherever the Vue component builds its axios config, and the value is attached as the X-XSRF-TOKEN header.

const axiosConfig = {
  headers:{
    'X-XSRF-TOKEN': getCookie('XSRF-RequestToken'),
  }
};
 
function getDirectApi() {
  axios.get(`${getCurrentHost()}/api/DirectApi`, axiosConfig)
    .then((response: any) => {
      jsonResponse.value = response.data;
      return response.data;
    })
    .catch((error: any) => {
      alert(error);
    });
}

On the server side, the AutoValidateAntiforgeryTokenAttribute filter checks that this header matches the token stored against the session for every non-GET request, and rejects the call if it does not. One thing worth flagging for production code: alert() on an API error is fine for a demo, but you will want proper error boundaries and a toast or notification component before this goes anywhere near real users.

Wiring Up the ASP.NET Core Host

The Program.cs is where authentication, the antiforgery policy, and the reverse proxy for development all come together.

var builder = WebApplication.CreateBuilder(args);
 
builder.WebHost.ConfigureKestrel(serverOptions =>
{
    serverOptions.AddServerHeader = false;
});
 
var services = builder.Services;
var configuration = builder.Configuration;
var env = builder.Environment;
 
services.AddScoped<MsGraphService>();
services.AddScoped<CaeClaimsChallengeService>();
 
services.AddAntiforgery(options =>
{
    options.HeaderName = "X-XSRF-TOKEN";
    options.Cookie.Name = "__Host-X-XSRF-TOKEN";
    options.Cookie.SameSite = SameSiteMode.Strict;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
 
var scopes = configuration.GetValue<string>("DownstreamApi:Scopes");
string[] initialScopes = scopes!.Split(' ');
 
services.AddMicrosoftIdentityWebAppAuthentication(configuration, "MicrosoftEntraID")
    .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
    .AddMicrosoftGraph("https://graph.microsoft.com/v1.0", initialScopes)
    .AddInMemoryTokenCaches();
 
services.AddControllersWithViews(options =>
    options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));
 
services.AddRazorPages().AddMvcOptions(options => { }).AddMicrosoftIdentityUI();
 
builder.Services.AddReverseProxy()
   .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
 
var app = builder.Build();
 
app.UseSecurityHeaders(
    SecurityHeadersDefinitions.GetHeaderPolicyCollection(env.IsDevelopment(),
        configuration["MicrosoftEntraID:Instance"]));
 
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseNoUnauthorizedRedirect("/api");
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
app.MapControllers();
app.MapNotFound("/api/{**segment}");
 
if (app.Environment.IsDevelopment())
{
    var uiDevServer = app.Configuration.GetValue<string>("UiDevServerUrl");
    if (!string.IsNullOrEmpty(uiDevServer))
    {
        app.MapReverseProxy();
    }
}
 
app.MapFallbackToPage("/_Host");
app.Run();

AddMicrosoftIdentityWebAppAuthentication does the heavy lifting of wiring up the OpenID Connect client and the cookie authentication scheme in one call, backed by Microsoft.Identity.Web. Notice app.UseNoUnauthorizedRedirect(“/api”) near the bottom. Without it, an unauthenticated call to an API route would get redirected to the login page and return a 302 with an HTML body, which most frontend HTTP clients handle badly since they expect a JSON 401. This line makes sure API routes return a proper 401 instead. The reverse proxy registration only activates in development when a UiDevServerUrl is configured, so production traffic never goes through YARP at all, it is served directly from static files.

Registering the Application in Microsoft Entra ID

Since the Vue.js build and the ASP.NET Core server ship as one deployable, you only need a single App Registration, even though two different technology stacks are involved. Register it as a Web client type, not SPA, because the OpenID Connect handshake happens server side.

Azure App Registration configured with the Web platform, matching the confidential client flow used by the server
Azure App Registration configured with the Web platform, matching the confidential client flow used by the server

The redirect URI configured here needs to match the CallbackPath your ASP.NET Core app listens on, which defaults to /signin-oidc. A common mistake is registering a SPA platform entry out of habit, which enables the implicit flow and PKCE without a client secret, neither of which this architecture needs or wants.

"MicrosoftEntraID": {
  "Instance": "https://login.microsoftonline.com/",
  "Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]",
  "TenantId": "[Enter your Tenant Id]",
  "ClientId": "[Enter the Application (client) ID]",
  "ClientSecret": "[Copy the client secret added to the app from the Azure portal]",
  "ClientCertificates": [],
  // required to handle Continuous Access Evaluation challenges
  "ClientCapabilities": [ "cp1" ],
  "CallbackPath": "/signin-oidc"
}

Do not commit a real ClientSecret to appsettings.json. For local development, store it with dotnet user-secrets, and for anything deployed to Azure, pull it from Key Vault or, better still, switch to certificate based client assertion, which is what this sample recommends for production. Certificates cannot leak through a misconfigured repository the way a plaintext secret can.

Local Development With the YARP Reverse Proxy

During development, the ASP.NET Core server and the Vite dev server run as two separate processes on two separate ports. YARP sits in front and proxies UI requests through to Vite, so from the browser’s point of view there is still only one origin, which keeps cookies and CSP behaving the same way they will in production.

Development topology: YARP reverse proxy in front of the ASP.NET Core host, forwarding UI requests to the Vite dev server while API and auth routes stay with the server
Development topology: YARP reverse proxy in front of the ASP.NET Core host, forwarding UI requests to the Vite dev server while API and auth routes stay with the server

This is a common trade-off in BFF setups. Some teams run YARP in production too, proxying to a separately deployed SPA container, while others, like this sample, only use it for development and bake the SPA into the server’s wwwroot for production. Running two processes locally does mean two terminals open, one for each half.

  • From the ui folder, run npm start to bring up the Vite dev server on its HTTPS port
  • From the server folder, run dotnet run to start the ASP.NET Core host, which proxies UI requests to Vite through YARP
  • Open the server’s localhost URL in the browser, not the Vite port directly, so the BFF cookie and CSP flow is exercised correctly

Before any of this works, appsettings.json needs your actual tenant details filled in against the MicrosoftEntraID section shown earlier. Skipping this step is the most common reason a first run fails with an unhelpful redirect loop.

Limitations Worth Knowing Before You Adopt This

The nonce injection in this sample does not reach Vite’s dev server styles, only the production build gets a correctly nonced stylesheet link. In practice this means your CSP has to be slightly looser in development than production if you want styles to render, which is exactly the kind of gap that lets a problem slip through and only get caught after a deployment. Worth tightening before you rely on this pattern as-is.

More broadly, this architecture suits applications where the UI and the API are tightly coupled from a business perspective, even though the tech stacks differ. If you are fronting a public API that other independent clients also consume, or you need the BFF to run as an independently scalable service in front of several UIs, look at the Duende.BFF library instead, which handles token proxying and session management as a more general purpose middleware rather than baking everything into Program.cs by hand. For a single team shipping one Vue front end against one Entra ID tenant, the approach in this sample is simpler to reason about and has fewer moving parts to operate.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading