Secure a Vue app with OpenID Connect and the BFF pattern
When building web applications, single-page framework applications have become one of the dominant forms of user experience creation. Developers have many choices, including React, Angular, Vue, and many more. While plenty of frameworks exist, only a few options exist for securing these applications.
At Duende, we recommend that developers adopt the Backend for Frontend (BFF) pattern to maintain a high-security posture and protect their users and data from malicious attacks.
In this post, we’ll look at the basic architecture of a BFF solution, the responsibilities of each component, and how it all fits together.
The Goal
The main objective of any security solution is to keep communication between components trusted and secured. In the case of a modern solution, two logical elements will communicate: the front end and the back end. We want to ensure that authenticated and authorized users can perform said actions when they act. Without the establishment of trust, sensitive information may be accessible to unintended users.
The good news is that OAuth 2.x and OpenID Connect are standards that go hand-in-hand with this architecture. A user will access an application, request an access token from an identity provider, and use that token to access a backing API. The bad news is that single-page and client-side applications require a secure place to store the access token. The browser’s local storage, used by the oidc-client-js and oidc-client-ts JavaScript libraries, can be accessed by the user using the Developer Tools (F12), making it easy to make requests to the API bypassing the intended application.
Even worse, any malicious JavaScript that makes its way into the SPA will have access to the access token too and can make API calls impersonating the authenticated user, or just exfiltrate it altogether.
In the following sections, we’ll introduce a BFF proxy, a trusted intermediary between the front and back end. As you’ll see, there are many advantages to using a BFF, and ultimately, it leads to successful and secure solution deployments.
The Backend For Frontend
What is a BFF? You can think of BFF in two crucial but related ways. First, BFF is an architectural pattern stating that every browser-based application should have a complimentary server-side application that handles all authentication requirements for the IETF definition of BFF. Secondly, the BFF solution provided by Duende is an intermediary between the front and back end. It is a trusted party in solution architecture and has several essential responsibilities:
- Server-side session management
- Server-side end-user authentication using OpenID Connect
- Requesting and managing access tokens to access APIs
- Cross-site Request Forgery attack protection
- Securely calling APIs
How does the BFF carry out these responsibilities? Using production-tested security patterns and practices, of course.
When communicating with the frontend, the elements communicate using HTTPS and cookie-based sessions. First-party cookies are the most trusted method of security in web development. A mix of HTTP-only cookies, expiration, CORS protections, and Same-Site policies makes attacks by malicious parties very challenging.
Interactions happen within a trusted context on the server, so developers can apply a mix of OAuth 2.x, OpenID Connect, and a library of security practices to ensure only trusted parties can communicate. Additionally, logging and auditing techniques can be applied here to quickly track and shut down any compromised sessions before they are used to exfiltrate user data. In general, you have much more control over a server-side environment than you can on a user’s machine, where you generally have little to no influence.
Let’s take a look at the architecture of a BFF solution.
In an ASP.NET Core host, adding a BFF is as straightforward as installing two NuGet packages.
<PackageReference Include="Duende.BFF" Version="3.0.0" />
<PackageReference Include="Duende.BFF.Yarp" Version="3.0.0" />
And then registering some ASP.NET Core infrastructure.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddBff().AddRemoteApis();
// Add Identity Provider
// ...
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseBff();
app.UseAuthorization();
app.MapBffManagementEndpoints();
app.MapRemoteBffApiEndpoint("/todos", "https://localhost:7001/todos")
.RequireAccessToken(Duende.Bff.TokenType.User);
In the next section, we’ll examine a Vue example and learn how to create a component that interacts with Duende’s BFF session management APIs.
The Frontend
Thanks to community member Marco Cabrera for providing the Vue sample code used in this post.
The frontend SPA framework is up to you, but we’ll use Vue in this post. From the previous section, you may have noticed a call to MapBffManagementEndponts
. These endpoints allow any frontend code to log in or out or check on a user’s active session.
Let’s start with the most straightforward action a frontend may want to initiate: a login.
export function login() {
window.location.href = '/bff/login';
}
That’s it! By redirecting a user to the login endpoint provided by our BFF, we will be redirected to the identity provider login page, where a typical OpenID Connect login will occur and ultimately set a cookie.
<div v-else>
<p>Please <a href="#" @click.prevent="login">Login</a> to view ToDos.</p>
</div>
Next, look at retrieving the user session and any associated claims.
export const isAuthenticated = ref(false);
export const user = ref(null);
export async function checkAuthStatus() {
try {
const res = await fetch('/bff/user', {
credentials: 'include',
headers: {
'X-CSRF': '1'
}
});
if (res.ok) {
isAuthenticated.value = true;
user.value = await res.json();
} else if (res.status === 401) {
isAuthenticated.value = false;
user.value = null;
console.log('User is not authenticated (401 from /bff/user).');
} else {
console.error('Error checking auth status:', res.status, res.statusText);
isAuthenticated.value = false;
user.value = null;
}
} catch (error) {
console.error('Network error checking auth status:', error);
isAuthenticated.value = false;
user.value = null;
}
}
Again, we use the BFF management endpoints to ask about the user’s current status, check the HTTP status codes, and handle each appropriately. Binding our user
to the UI is as straightforward as you’d imagine.
<table class="table table-striped table-bordered">
<thead class="thead-dark">
<tr>
<th>Claim Type</th>
<th>Claim Value</th>
</tr>
</thead>
<tbody>
<tr v-for="(claim, index) in user" :key="index">
<td>{{ claim.type }}</td>
<td>{{ claim.value }}</td>
</tr>
</tbody>
</table>
So far, we’ve only used BFF management endpoints, but what about custom APIs? Well, we’ve already registered one at /todos
. Securely calling APIs is as straightforward as calling the management APIs.
const todos = ref([]);
// Fetch ToDos from the backend (no changes needed here)
async function fetchTodos() {
loading.value = true;
error.value = null;
try {
todos.value = await fetchApi('/todos'); // GET request
} catch (err) {
error.value = err.message || 'Failed to fetch ToDos.';
todos.value = []; // Clear potentially stale data
} finally {
loading.value = false;
}
}
The fetchApi
call handles adding the X-CSRF
security header and handling JSON responses. Retrieving JSON from APIs is as simple as using fetch
on BFF-secured endpoints and relying on cookie-based authentication.
The following section will examine what it takes to set up the BFF so that our front end and API can communicate securely.
The Backend
ASP.NET Core is a best-in-breed technology stack for building web APIs. Let’s examine a few of the endpoints powering our front-end experience.
group.MapGet("/", () => data);
group.MapGet("/{id}", (int id) =>
{
var item = data.FirstOrDefault(x => x.Id == id);
}).WithName("todo#show");
// POST
group.MapPost("/", (ToDo model, ClaimsPrincipal user, LinkGenerator links) =>
{
model.Id = ToDo.NewId();
model.User = $"{user.FindFirst("sub")?.Value} ({user.FindFirst("name")?.Value})";
data.Add(model);
var url = links.GetPathByName("todo#show", new { id = model.Id });
return Results.Created(url, model);
});
Now, where does authentication fit into this API definition? We must add JSON Web Token (JWT) authentication and authorization policies for each endpoint.
using Vue.Api;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options =>
{
options.Authority = "https://demo.duendesoftware.com";
options.Audience = "api";
options.MapInboundClaims = false;
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ApiCaller", policy =>
{
policy.RequireClaim("scope", "api");
});
options.AddPolicy("InteractiveUser", policy =>
{
policy.RequireClaim("sub");
});
});
var app = builder.Build();
app.UseHttpsRedirection();
app.MapGroup("/todos")
.ToDoGroup()
.RequireAuthorization("ApiCaller", "InteractiveUser")
.WithOpenApi();
app.Run();
As you can see in the code, we are using the constructs provided by ASP.NET Core to secure our APIs. While we implemented this API definition within our BFF architecture, it doesn’t rely on any BFF-specific code.
Conclusion
In this quick overview of BFF, we defined the architectural pattern, the critical elements of a BFF solution, and how they all relate. We also showed how secure communication between the front and back end can be as seamless as calling fetch
on exposed endpoints. By adopting a BFF, you can reduce boilerplate code in your UI while improving the overall security posture of your solutions.
To learn more about BFF, check our official BFF documentation for more insights and samples.