This post isn’t about whether or not digital twins are cool. Instead, it reflects an observation I didn’t expect going in. The way this particular twin came together, it was really three refinement pipelines feeding one scene: geometry (LiDAR to mesh), telemetry (Eventhouse to tools), and language (tools to agent). I’m not claiming every digital twin looks like this, but in this build, the three pipelines shared one discipline: Reduce the data as far upstream as you can, keep the runtime thin, and generate every downstream artifact from a single source of truth.
In the video above, you’re looking at a real place but a simulated wind farm: a site in Sievi, Finland, its terrain built from open laser-scanning data, its turbines fed by live telemetry from a Fabric Eventhouse. You can orbit it, pull a turbine apart component by component, and ask (in either typed or spoken language) which turbine is underperforming and what it’s costing. The goal: Build a realistic and hands-free digital twin experience within Microsoft Fabric.

Digital twin application embedded in Fabric Portal
Behind the scenes, there’s a stack of features: a React + Vite + Three.js frontend hosted as a Microsoft Fabric app via Rayfin, Fabric Real-Time Intelligence for telemetry, and a managed Microsoft Foundry agent for the language layer.

Architecture overview.
Rayfin is an open-source SDK and CLI for building apps whose backend lives inside Microsoft Fabric. You define entities as decorated TypeScript classes, run npx rayfin up, and it provisions the SQL tables, Fabric SSO, APIs, and static hosting. No infrastructure to stand up before the first pixel:
@entity()
@authenticated('*')
export class Turbine {
@uuid() id!: string;
@text({ max: 40, unique: true }) turbineId!: string; // "WT-01"
@text({ max: 120 }) name!: string;
@decimal() ratedCapacity!: number;
@decimal({ precision: 9, scale: 5, optional: true }) latitude?: number;
@many(() => Component) components?: Component[];
}
The part that paid off later: This data model is the contract for everything downstream. The same Turbine and Component entities that hold seed data become the binding targets for the 3D model (each part is named after a component) and the grounding vocabulary for the agent (tools speak in turbineIds). Get the model right once; reuse it three times.
One constraint shaped the architecture: Rayfin’s functions were not available in the current preview state streaming functions are not supported. Anything cross-origin (streaming chat, voice token minting, tool execution) can’t live there. So those moved into a deliberately thin backend deployed as its own Container App:
Browser (Fabric-embedded React + Three.js)
├─ chat ──► thin backend /api/assistant/* (streamed NDJSON)
├─ voice ──► thin backend /api/realtime/* → WebRTC direct to Azure OpenAI Realtime
└─ tools ──► thin backend /api/tools/:name (deterministic Fabric RTI tools)
I wanted the terrain to match the physical site, not a procedurally generated stand-in. The National Land Survey of Finland (Maanmittauslaitos) publishes open geodata under CC BY 4.0, and my first attempt used the obvious source: vector map height contours. That didn’t work as the contours were far too sparse to build a convincing surface.

Vector map height contours.
What did work was their LiDAR data: laser-scanned point clouds at 0.5 points/m², delivered as LAZ tiles. For our area, 7.7 million points.

LiDAR point cloud visualized.
For our digital twin, this source data was too detailed to load directly into the browser. A single LAZ tile can contain millions of points, and rendering that raw point cloud in Three.js would be unnecessarily heavy for an interactive web application. Instead, I used the LAZ data as a high-quality source for generating a lighter terrain representation.
The processing step converts the point cloud into a browser-friendly height model. Conceptually, the workflow looks like this:
The final mesh is much lighter than the original point cloud: over 7.7 million source points reduced to roughly 6,500 vertices and 12,800 triangles (an 80×80 segment plane). That makes it suitable for an interactive browser-based digital twin, where we also need to render turbines, labels, weather effects, camera controls, and live operational data.
The sparse-but-semantic vector layers we rejected for elevation earn their keep on top of it: Roads, rivers, fields, water, and powerlines drape onto the mesh, so the terrain isn’t just shaped like the real place—it also reads like it.

Final generated terrain with roads, contours, trees, and other features. Terrain/map data: National Land Survey of Finland (Maanmittauslaitos), CC BY 4.0.
The turbine model had its own failed first attempt: procedural generation directly in Three.js, which produced geometry I can only describe as legally distinct from a wind turbine. The approach that worked was moving the generation offline. I used GitHub Copilot with Opus 4.8 to write a Blender Python script that builds the turbine and exports a compact GLB with separately named parts (rotor, nacelle, tower). Same principle as the terrain: Do the heavy work upstream; ship a small artifact.
From simple geometry to realistic model with BOM.
The rendering layer is intentionally boring: HomePage → Twin3DView → SceneEngine, where Twin3DView is a thin React wrapper and the SceneEngine is ~300 lines of framework-agnostic Three.js that knows nothing about React. The render loop never fights React’s lifecycle, and the engine is testable without mounting a component tree. The GLB loads once as a prototype and is cloned per turbine, so each instance gets its own material state and risk coloring, hover highlighting via raycasting against those named parts, and rotor animation driven by live wind data.

I was faced with a hard task when deciding what kind of agent should power the digital twin. I knew that the requirement of having real-time capable voice chat would drive my decisions further. The agent is where most of the design decisions live, and it’s where I’d defend them hardest.
In order to drive real-time voice chat, I would need a model that supports this and would provide a good (and multilingual) experience. Real-time voice models allow you to build a natural conversation experience where you can interrupt and have more natural conversation. I decided to use gpt-realtime-2 from Microsoft Foundry for this task. It’s OpenAI’s advanced real-time voice model, capable of speech-to-speech interactions with GPT-5-class reasoning and tool calling. This will come in handy when we need to ground our answers in data.
In order to keep the conversation flowing and natural, the agent would need to answer fast. Usually building an agent that reasons over data, we would either use some NL2SQL generation or out-of-the box solution like Fabric IQ. But this time we would want the answer in a fraction of a second rather than waiting multiple seconds for an answer.
So instead of NL2SQL, I built deterministic tools that fetch data from Fabric KQL Database. Each of the tools calls a function in KQL database that returns the requested data.

Tool calling latency.
With this approach, the tool calls take just over 100ms for most of the tools, including network latency.
This approach of having real-time voice model and deterministic tools enables us to build a voice chat experience that feels like actual conversation rather than a simple question-and-answer machine.

End-to-end latency for simulated voice chat.
Three findings stuck with me after shipping this:
For a voice-enabled twin, generated SQL isn’t fast enough. When someone is speaking to the twin, they expect an answer before the pause gets awkward. I started down the obvious path (NL2SQL, or an out-of-the-box option like Fabric IQ), and the latency killed the conversation. What worked was the opposite of clever: a small set of deterministic, purpose-built tools backed by KQL functions. Most of them return in just over 100ms including network, which is what keeps the whole exchange feeling like a conversation instead of a query.
Open data will carry more of the build than you’d expect. The terrain here isn’t a stand-in. It’s the real ground. Free, openly licensed geodata took this from “a plausible-looking hill” to “the actual site,” and that credibility is what makes the scene feel operational rather than decorative.
Complex, 3D, agentic apps can be built fast now. This is a React and Three.js frontend on a Fabric backend, a Blender-generated turbine, a real-time voice agent, and a KQL tool layer. A year ago, that would have been a multi-week project. GitHub Copilot wrote the Blender script, scaffolded the scene, and helped sequence the whole plan, and this was ready in days, not weeks.
The interesting part isn’t that the AI wrote code. It’s that the bottleneck moved from typing to deciding, so the time went into making architecture choices that mattered.
The post A wind farm you can talk to: Building a real-time 3D digital twin on Microsoft Fabric appeared first on Command Line.
C# Expressions mean you can now skip the Converters and add logic directly in your XAML using C#. This is great news for .MET MAUI developers!
Improving and optimizing your code should be your top priority every single day. As a .NET MAUI developer, I know Converters are very familiar to you. They’ve helped us transform data and adapt it to the UI. However, we can all agree that sometimes they made our code a bit more verbose than necessary.
But here’s some BIG news … and honestly, one of the most impactful changes in recent times. The Microsoft team, always thinking about developer experience, now allows us to say goodbye to Converters and start using C# Expressions instead!
What does this mean? You can now add logic directly in your XAML using C#: perform math operations, use string interpolation, boolean logic and much more. In this article, we’ll walk through before-and-after scenarios so you can clearly see how much code you can save, not to mention how much more readable and team-friendly your code will become.
Grab your coffee ☕ and let’s get started!
Today, we’ll dive directly into the code to explore the differences and how we can use them moving forward. Each example will be broken down into subtopics so you can clearly understand the before and after.
When we need to connect the value of a property with the UI, we typically use Bindings followed by the property name. For example, if we want to display values from properties like FullName or Company, we would write something like this:
<Label Text="{Binding FullName}" />
<Label Text="{Binding Company}" />
Now, what if I told you that you can achieve the same result without using Bindings?
With C# Expressions, you can do it in two different ways:
Implicit syntax
<Label Text="{FullName}" />
<Label Text="{Company}" />
Explicit syntax
You can also use the = prefix to make it more explicit that you’re using a C# expression.
<Label Text="{= FullName}" />
<Label Text="{= Company}" />
There is no functional difference between the implicit and explicit approaches; both achieve the same result. You can use whichever style you feel more comfortable with or that best fits your team’s coding standards.
We’re used to formatting strings using StringFormat. For example, if we want to display a price like “Total: $28 USD,” we first need to define the Binding with the property, and then apply the format using StringFormat='Total: ${0} USD'.
If we need to include more properties in the format, we must keep adding placeholders, which can make the expression more complex and harder to read as it grows.
<Label Text="{Binding Price, StringFormat='Total: ${0} USD'}" FontSize="18" TextColor="Green" />
With C# Expressions, you can do it directly. Notice how much simpler and more readable the same expression becomes compared to the previous approach:
<Label Text="{=$'Total: ${Price} USD'}" FontSize="18" TextColor="Green" />
I’m speechless … you can now perform calculations directly in XAML.
Before seeing how this works now, let’s look at how we’ve traditionally handled this scenario. To achieve this before, we usually needed to create a computed property in the ViewModel, something like:
public decimal Subtotal => UnitPrice * ItemsCount;
And then bind it in XAML:
<Label Text="{Binding Subtotal, StringFormat='Subtotal: ${0:F2}'}"
FontSize="20"
FontAttributes="Bold" />
One of the most exciting improvements is that we can now perform calculations directly in XAML.
For example, if we want to display the subtotal of a purchase based on the unit price and the number of items, we no longer need a computed property or a converter for that. We can write the expression exactly where we need it:
<Label Text="{=$'Subtotal: ${UnitPrice * ItemsCount:F2}'}"
FontSize="20"
FontAttributes="Bold" />
In case you still had any doubts about saying goodbye to Converters …
Before, inverting a boolean value was not that straightforward. If you wanted to show or hide an element based on the opposite value of a property, you couldn’t do it directly in XAML. This required creating a converter, registering it in the resources, and then applying it in the binding. A process that, for such a small piece of logic, ended up being unnecessarily complex.
For example, let’s look at this scenario where we want to display a message when the user is not logged in:
<Label Text="Please log in to continue"
IsVisible="{Binding IsLoggedIn, Converter={StaticResource BooleanInvertConverter}}"
TextColor="Red"
FontSize="16" />
And also:
<local:BooleanInvertConverter x:Key="BooleanInvertConverter" />
public class BooleanInvertConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)C
=> !(bool)value;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> !(bool)value;
}
Look how simple this becomes with just this small piece of code:
<Label Text="Please log in to continue"
IsVisible="{=!IsLoggedIn}"
TextColor="Red"
FontSize="16" />
Just one line. No converters, no extra classes, no additional setup.
And of course, you can also work with boolean expressions without negation, like this:
<Switch IsToggled="{HasAccount}" />
Traditionally, to handle events in .NET MAUI, we needed to create methods in the code-behind or define Commands in the ViewModel. This meant more code, more structure and, in many cases, more complexity than necessary.
But before we see how this looks now, let’s take a look at how we’ve been doing it so far:
<Button Text="{Binding ClickCount, StringFormat='Clicked {0} times'}"
Command="{Binding IncrementCommand}"
BackgroundColor="DarkOrange"
TextColor="White" />
In the view model:
public ICommand IncrementCommand { get; }
public int ClickCount { get; set; }
public MainViewModel()
{
IncrementCommand = new Command(() =>
{
ClickCount++;
});
}
Thanks to C# Expressions, we can also use lambda expressions directly in XAML to handle events. This allows us to execute logic inline, exactly where we need it, making the code more direct, cleaner and much easier to read for simple interactions.
<Button Text="{=$'Clicked {ClickCount} times'}"
Clicked="{(s, e) => ClickCount++}"
BackgroundColor="DarkOrange"
TextColor="White" />
And that’s it! In this article, we explored how C# Expressions are changing the way we build .NET MAUI apps. From simple bindings, string formatting and calculations, to boolean logic and even event handling… we saw how we can now write less code, make it more direct, and achieve things that previously required much more setup and effort.
I hope this article helps you start simplifying your own code and encourages you to take advantage of this more modern and expressive approach in your .NET MAUI applications.
If you have any questions or would like me to dive deeper into any of these scenarios, feel free to leave a comment, I’ll be happy to help you!
See you in the next article! ♀️
I used a video by Gerald Versluis as a reference for creating this article:
In my earlier overview of how compilers on different architectures perform stack probes, Cole Tobin asked, “Why not have a page fault handler that detects the faulting address being the stack and page in the other pages?”
Csaba Varga replied, “My guess: you don’t want an invalid pointer dereference to allocate a huge chunk of stack, just because the pointer happens to be pointing where the stack might grow, eventually. You want an invalid pointer dereference to segfault most of the time.”
I agree with Csaba on this.
If the entire stack were made of guard pages, then it means that a single page fault far below the stack limit could take arbitrary long and allocate arbitrarily large quantities of memory. The program might have said that it wants stacks to default to 1GB, and now a single page fault on the stack could result in a long pause as the system allocates 1GB of memory. If you study the problem in the debugger, what you see is that a single memory read takes several minutes.
And even worse is that there’s no way to stop it, since it’s happening in kernel mode. You see a program starting to balloon and consume all the memory in the system, so you go to Task Manager and terminate it, but the process doesn’t die. It just keeps on growing!
Even if the guard page is more than one page, it’s still a small fixed number of pages, the system can satisfy a guard page fault in a short amount of time. And more importantly, the amount of work is bounded.
The post Why don’t we just make the entire stack out of guard pages? appeared first on The Old New Thing.
Explore how to secure agents built with Mastra, using a demo Next.js application integrated with a simple Mastra agent and authentication and authorization in Firebase.
AI agents have moved beyond the experimental stage and are now being deployed in real-world applications. Unlike a traditional API endpoint, an agent can reason, make decisions and handle complex tasks on behalf of a user. This shift is exciting, but it raises important concerns about knowing exactly who is calling an agent and what they are allowed to do.
When an agent is built and deployed, it can be directly accessed by anyone. This can result in wrongful consumption of API credits, and it poses a significant risk in use cases where the agent is connected to sensitive tools such as databases, file systems and email systems. This is where authentication and authorization come into play. It is important to implement such security protocols to verify the identity and permissions of an agent’s users.
In this article, we’ll explore how to secure agents built with Mastra, a framework for building agents and other AI-powered applications. We’ll build a demo Next.js application integrated with a simple Mastra agent, then implement authentication and authorization using Firebase.
To follow along with this article, you should have:
To understand why security matters here, let’s first understand what an AI agent actually is and how it differs from a regular application. A traditional application is fixed in its behavior. When an API endpoint is called, it runs a fixed block of code and returns a predictable response. There is no reasoning, no decision-making or any action other than what was explicitly programmed.
An AI agent is quite different. Instead of following a fixed preset, an agent receives an open-ended instruction and figures out how to fulfill it. It can reason about a problem, decide which tools to use, call those tools, interpret the results and take further action, all in a single interaction.
Now let’s talk about security. When you expose an API endpoint, you know exactly what it can do. On the other hand, when you expose an agent, the surface area is much wider. An agent connected to a database can read and write records. An agent connected to email can send messages on someone’s behalf. An agent connected to a payment system can initiate transactions. If an unauthorized person reaches that agent, they could potentially instruct it to do any of those things.
The more capable an agent is, the more important it is to control who can reach it and what it can be asked to do. Also, note that calls to AI agents mostly come at the expense of API credits, so it is important to regulate usage in order to avoid incurring unnecessary costs.
These two words are often used interchangeably and misplaced for each other. However, they mean different things and solve different problems.
Authentication answers the question “Who are you?” It verifies the identity of the person making a request. Generally, a user signs in, proves who they are, and receives a token that proves their identity on subsequent requests.
Authorization answers the question “What are you allowed to do?” It verifies that the authenticated user actually has permission to perform the action they’re requesting.
There are different ways to implement authentication and authorization on Mastra AI agents. In this article, we are using Firebase for several reasons. First, Firebase is a fully managed service. It abstracts away the complexity of OAuth flows, token signing, token expiration, session management and much more.
Next, with Firebase’s Firestore, we don’t need to set up a separate database just to store and manage user roles. Firestore is a natural place to store user roles because it is already in the Firebase ecosystem. Firestore also has built-in security rules, which means we can control and manage access.
Finally, Mastra has first-class support for Firebase through the @mastra/auth-firebase package. This means that protecting Mastra’s own internal server endpoints is straightforward and requires very little configuration.
So far, we’ve touched briefly on what AI agents are, the need for security and why we’re using Firebase in this article. We can now start building our demo application to see how authentication and authorization can be implemented on Mastra AI agents using Firebase.
For the demo, we’ll build a simple prompt-and-response application that integrates with a Mastra agent under the hood. We’ll configure security beyond just agent protection. Security here is applied in four different layers.
Layer 1 redirects all unauthenticated users to the login page. At Layer 2, every request to the API route must carry a valid Firebase token. If it doesn’t, the request is rejected with a 401 error before anything else happens. This cannot be bypassed in the browser because it runs entirely on the server.
At Layer 3, once the token is verified and the server knows who the user is, it checks their role in Firestore. If their role does not qualify, the request is rejected with a 403 error. At this point, the agent has not even been triggered.
Layer 4, which is the core of this article, uses the MastraAuthFirebase class to apply the same Firebase token verification to Mastra’s own endpoints. Note that this security configuration can be approached in different ways. The most important thing is to properly define the MastraAuthFirebase class.
To get started, run the command below in your terminal to create a new Next.js application:
npx create-next-app@latest test-nextjs-agent
This creates a project called test-nextjs-agent. Feel free to rename the project, and complete the resulting prompts as shown below:

Run the following commands to navigate into the newly created project and start the development server:
cd test-nextjs-agent
npm run dev
Open a new terminal and run the code below to initialize Mastra:
npx mastra@latest init
This installs the Mastra core dependencies and scaffolds the necessary folders and configurations without generating a new project from scratch.
In the resulting prompt when you run the command above, select a default model provider from the list provided. Here, we’ll use Google, but you can select any other provider of your choice.
There is also a prompt to provide the API key for the selected provider. Since we’re using Google, you can create and retrieve a new API key from Google AI Studio, as shown below:

You can skip this step, but you’ll need to create a .env file and define the API key manually, as shown below:
GOOGLE_GENERATIVE_AI_API_KEY=YOUR-GOOGLE-API-KEY
You should now see a new /mastra folder with some predefined files inside the /src folder. It is important to take note of the following files:
/mastra/index.ts: This holds a Mastra instance, imported from @mastra/core/mastra./mastra/agents/weather-agent.ts: Holds a test weather agent predefined by the Mastra team. The agent uses the google/gemini-2.5-pro model by default. For testing purposes, you can change that to google/gemini-2.5-flash, which is a less expensive model./mastra/tools/weather-tool.ts: Contains a tool that fetches weather data and integrates with the weather agent.We can see this agent in action by running this command in the terminal:
npx mastra dev
The command starts a server that exposes Mastra Studio and REST endpoints for your agents, tools and workflows. Open http://localhost:4111/ to access Mastra Studio, which has the test agent ready to be interacted with.

Our focus for this article is on agent authentication and authorization in Mastra using Firebase, so we won’t consider the inner workings of creating agents, tools, workflows and the rest. Instead, we’ll work with this predefined agent. However, you can explore the official documentation to learn more.
Since we now have our instance of Mastra up and running, we should be able to call it in our demo Next.js project.
Let’s create a chat API route. Create a src/app/api/chat/route.ts file and add the following to it:
import { mastra } from "@/mastra";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { message } = await req.json();
if (!message) {
return NextResponse.json({ error: "Message is required" }, { status: 400 });
}
const agent = mastra.getAgent("weatherAgent");
const result = await agent.generate([{ role: "user", content: message }]);
return NextResponse.json({ response: result.text });
}
The code above creates an API route that receives a message and has the agent instance generate a response based on the message. We’re intentionally keeping this route simple for demo purposes, with no streaming, memory, thread IDs, etc.
Next, let’s create a simple chat page on the frontend. Replace the contents of your src/app/page.tsx with the following:
"use client";
import { useState } from "react";
export default function Home() {
const [message, setMessage] = useState("");
const [response, setResponse] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!message.trim()) return;
setLoading(true);
setResponse("");
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
const data = await res.json();
setResponse(data.response);
setLoading(false);
};
return (
<main className="flex min-h-screen flex-col items-center justify-center p-8 gap-6">
<h1 className="text-2xl font-bold">Weather Agent</h1>
<textarea
className="w-full max-w-lg border rounded p-3 text-sm"
rows={3}
placeholder="Ask about the weather anywhere..."
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button
onClick={handleSubmit}
disabled={loading}
className="bg-blue-600 text-white px-6 py-2 rounded disabled:opacity-50"
>
{loading ? "Thinking..." : "Send"}
</button>
{response && (
<div className="w-full max-w-lg border rounded p-4 bg-gray-50 text-sm">
{response}
</div>
)}
</main>
);
}
Here, we created a simple chat page where we can send a message and have the returned response rendered on the screen. Open http://localhost:3000/ in your browser to see the updated demo project.

With our agent up and running, the next step is securing it. Right now, it is completely open, and when deployed, anyone with the URL can interact with it. To fix this, we’ll introduce Firebase as our authentication and authorization layer so that only verified users can access both the agent and the demo project as a whole.
Let’s start by setting up and configuring a Firebase project. Go to Firebase and create a new Firebase project. You can call it test-nextjs-agent or give it any other name you prefer.

On the left sidebar in the dashboard, go to Security → Authentication and click “Get started.”

Enable the Google sign-in provider, set a public-facing name and support email, and then click “Save.” You can also work with any other provider you prefer. For this demo, we’ll use Google.
Next, in the sidebar, go to Project Settings → General tab. Scroll down to “Your apps” and click the web icon (</>). Register a web app with any nickname.

Firebase will display a firebaseConfig object as shown below. Copy this object and save it, as we’ll be using it later:
const firebaseConfig = {
apiKey: "...YOUR-API-KEY",
authDomain: "test-nextjs-agent.firebaseapp.com",
projectId: "test-nextjs-agent",
...
};
Next, go to Project Settings and click on the Service Accounts tab. Then click to generate a new private key. This will download a JSON file that serves as the server’s proof of identity to Firebase.

This service account file is very important. It enables server-side access to Firebase. Do not commit it to your project’s version control. Move the file to the project’s root folder and add it to .gitignore immediately.
Next, run the command below:
npm install firebase firebase-admin @mastra/auth-firebase
This installs the following:
firebase: The Firebase client-side SDK that runs in the browser. Since we’re using Google sign-in here, it handles the sign-in pop-up and manages the user’s ID token.firebase-admin: The server-side SDK that runs in Next.js API routes. It verifies that the ID token the browser sent is real and hasn’t been tampered with.@mastra/auth-firebase: Mastra’s built-in auth wrapper for Firebase.Finally, open the .env file and update the environment variables as shown below:
GOOGLE_GENERATIVE_AI_API_KEY=YOUR-GOOGLE-API-KEY
NEXT_PUBLIC_FIREBASE_API_KEY=YOUR-FIREBASE-API-KEY
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=test-nextjs-agent.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=test-nextjs-agent
FIREBASE_SERVICE_ACCOUNT=/absolute/path/to/the/service/account/file.json
Here, we included environment variables required for both the frontend and backend. The firebaseConfig object we copied earlier contains the frontend values, while FIREBASE_SERVICE_ACCOUNT is an absolute path to the service account file we downloaded.
If you’re unsure how to get the absolute path, run the command below in the terminal:
pwd
Copy the returned path, then append the filename and its extension to form the full file path.
In this section, we’ll see how to implement Firebase authentication across our application, including for our Mastra agent.
Create a src/lib/firebase.ts file and add the following to it to initialize the Firebase client SDK:
import { initializeApp, getApps, getApp } from "firebase/app";
import { getAuth, GoogleAuthProvider } from "firebase/auth";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
};
const app = getApps().length ? getApp() : initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const googleProvider = new GoogleAuthProvider();
This sets up a Firebase app instance using environment variables, with a guard to prevent duplicate initialization during Next.js hot reloads. It then exports the Firebase Authentication instance and a Google sign-in provider, both ready to be used anywhere in the app.
We also need to initialize the Firebase Admin SDK. Create a src/lib/firebase-admin.ts file and add the following to it:
import admin from "firebase-admin";
import fs from "fs";
if (!admin.apps.length) {
const serviceAccountPath = process.env.FIREBASE_SERVICE_ACCOUNT as string;
const serviceAccount = JSON.parse(
fs.readFileSync(serviceAccountPath, "utf-8"),
);
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
}
export const adminAuth = admin.auth();
In the code above, we initialized the Firebase Admin SDK, the server-side counterpart to the client-side Firebase setup. It reads the service account JSON file from the path specified in the FIREBASE_SERVICE_ACCOUNT environment variable, uses it to authenticate as a trusted admin and exports adminAuth, which will be used server-side to verify and decode user tokens sent from the client.
Next, create a src/context/AuthContext.tsx file and add the following to it:
"use client";
import {
createContext,
useContext,
useEffect,
useState,
ReactNode,
} from "react";
import { onAuthStateChanged, User } from "firebase/auth";
import { auth } from "@/lib/firebase";
interface AuthContextValue {
user: User | null;
loading: boolean;
}
const AuthContext = createContext<AuthContextValue>({
user: null,
loading: true,
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => {
setUser(firebaseUser);
setLoading(false);
});
return () => unsubscribe();
}, []);
return (
<AuthContext.Provider value={{ user, loading }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
This file creates a React context that tracks the currently signed-in Firebase user across the app, exposing a user object and a loading state. The useAuth hook is then exported to allow access to the context values from any component.
Open the src/app/layout.tsx file and wrap the application with the context provider, AuthProvider, as shown below:
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/context/AuthContext";
//...
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}
Now, let’s create a simple login page that will be used to test the Firebase authentication in the browser.
Create a src/app/login/page.tsx file and add the following to it:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { signInWithPopup } from "firebase/auth";
import { auth, googleProvider } from "@/lib/firebase";
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const handleSignIn = async () => {
try {
await signInWithPopup(auth, googleProvider);
router.push("/");
} catch (err) {
console.error(err);
setError("Sign-in failed. Please try again.");
}
};
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-8">
<h1 className="text-2xl font-bold">Weather Agent</h1>
<p className="text-gray-500">Sign in to continue</p>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
onClick={handleSignIn}
className="bg-blue-600 text-white px-6 py-2 rounded"
>
Sign in with Google
</button>
</main>
);
}
This renders a “Sign in with Google” button that triggers a Firebase Google pop-up sign-in. On success, the user is redirected to the homepage; on failure, an error message is displayed.
Now, let’s protect our chat page and also attach the token. Open the src/app/page.tsx file and update it as shown below:
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { User } from "firebase/auth";
import { signOut } from "firebase/auth";
import { auth } from "@/lib/firebase";
import { useAuth } from "@/context/AuthContext";
export default function Home() {
const { user, loading } = useAuth();
const router = useRouter();
const userRef = useRef<User | null>(null);
const [message, setMessage] = useState("");
const [response, setResponse] = useState("");
const [fetching, setFetching] = useState(false);
useEffect(() => {
userRef.current = user;
}, [user]);
useEffect(() => {
if (!loading && !user) {
router.push("/login");
}
}, [user, loading, router]);
const handleSubmit = async () => {
if (!message.trim() || !userRef.current) return;
setFetching(true);
setResponse("");
const token = await userRef.current.getIdToken(true);
const res = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ message }),
});
const data = await res.json();
setResponse(data.response ?? data.error);
setFetching(false);
};
const handleSignOut = async () => {
await signOut(auth);
router.push("/login");
};
if (loading || !user) return null;
return (
<main className="flex min-h-screen flex-col items-center justify-center p-8 gap-6">
<div className="w-full max-w-lg flex justify-between items-center">
<h1 className="text-2xl font-bold">Weather Agent</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-500">{user.email}</span>
<button
onClick={handleSignOut}
className="text-sm text-red-500 hover:underline"
>
Sign out
</button>
</div>
</div>
<textarea
className="w-full max-w-lg border rounded p-3 text-sm"
rows={3}
placeholder="Ask about the weather anywhere..."
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button
onClick={handleSubmit}
disabled={fetching}
className="bg-blue-600 text-white px-6 py-2 rounded disabled:opacity-50"
>
{fetching ? "Thinking..." : "Send"}
</button>
{response && (
<div className="w-full max-w-lg border rounded p-4 bg-gray-50 text-sm">
{response}
</div>
)}
</main>
);
}
The code above redirects a user if not authenticated. On the other hand, if authenticated, it presents the user with a textarea to send messages to the weather agent.
Before each request, it fetches a fresh Firebase ID token and attaches it to the Authorization header when calling the /api/chat endpoint. It also displays the agent’s response and provides a sign-out button.
That’s it for the client side. Now let’s update the API route to verify the token. Open the src/app/api/chat/route.ts file and update the code as shown below:
import { mastra } from "@/mastra";
import { NextResponse } from "next/server";
import { adminAuth } from "@/lib/firebase-admin";
export async function POST(req: Request) {
const authHeader = req.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const idToken = authHeader.split("Bearer ")[1];
try {
await adminAuth.verifyIdToken(idToken);
} catch {
return NextResponse.json(
{ error: "Invalid or expired token" },
{ status: 401 },
);
}
const { message } = await req.json();
if (!message) {
return NextResponse.json({ error: "Message is required" }, { status: 400 });
}
const agent = mastra.getAgent("weatherAgent");
const result = await agent.generate([{ role: "user", content: message }]);
return NextResponse.json({ response: result.text });
}
In the code above, instead of just receiving messages from the client side and generating responses based on that, we now include an authentication verification layer.
First, it validates the Authorization header, extracting and verifying the Firebase ID token using the Admin SDK. It rejects any request that is unauthenticated or carries an invalid token. If verification passes, it forwards the user’s message to the Mastra weather agent and returns the agent’s response.
For the final piece, which is adding the authentication guard to our Mastra instance, update the src/mastra/index.ts file as shown below:
import { Mastra } from "@mastra/core/mastra";
import { PinoLogger } from "@mastra/loggers";
import { LibSQLStore } from "@mastra/libsql";
import {
Observability,
DefaultExporter,
CloudExporter,
SensitiveDataFilter,
} from "@mastra/observability";
import { weatherWorkflow } from "./workflows/weather-workflow";
import { weatherAgent } from "./agents/weather-agent";
import { MastraAuthFirebase } from "@mastra/auth-firebase";
export const mastra = new Mastra({
workflows: { weatherWorkflow },
agents: { weatherAgent },
storage: new LibSQLStore({
id: "mastra-storage",
url: "file:./mastra.db",
}),
server: {
auth: new MastraAuthFirebase(),
},
logger: new PinoLogger({
name: "Mastra",
level: "info",
}),
observability: new Observability({
configs: {
default: {
serviceName: "mastra",
exporters: [
new DefaultExporter(),
new CloudExporter(),
],
spanOutputProcessors: [
new SensitiveDataFilter(),
],
},
},
}),
});
For now, pay close attention only to this part:
server: {
auth: new MastraAuthFirebase(),
},
By passing an instance of the MastraAuthFirebase class to the auth option in the Mastra server config, Mastra will automatically intercept every incoming request to the agent and validate the Firebase ID token, thereby restricting access if no token is provided or if an invalid token is provided.
It is important to note that MastraAuthFirebase will automatically read the FIREBASE_SERVICE_ACCOUNT and FIRESTORE_DATABASE_ID environment variables, so no additional configuration is required other than verifying those values are set in the .env file.
Now you can test the application in your browser to see the authentication in action.

With authentication done, our agent is no longer publicly accessible so only signed-in users can access it. In this section, we’ll add an extra layer of control, which involves checking whether an authenticated user actually has permission to use the agent.
For this, we need somewhere to store user roles. Firestore is the best fit for this, since it is already part of the Firebase ecosystem.
In the sidebar of the Firebase dashboard, go to Databases and Storage → Firestore and click “Create Database.” Next, pick a region and select “Start in test mode.”
After successfully creating it, open the .env file and add the database ID as shown below:
//...previous environment variables
FIRESTORE_DATABASE_ID=(default)
In the snippet above, (default) is the ID Firestore assigns to the first database it creates for a project.
Now, open the src/lib/firebase-admin.ts file to update the Firestore admin to export Firestore:
import admin from "firebase-admin";
import fs from "fs";
if (!admin.apps.length) {
const serviceAccountPath = process.env.FIREBASE_SERVICE_ACCOUNT as string;
const serviceAccount = JSON.parse(
fs.readFileSync(serviceAccountPath, "utf-8"),
);
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
}
export const adminAuth = admin.auth();
export const adminFirestore = admin.firestore();
Here, admin.firestore() provides a Firestore client that uses the service account credentials. Instead of writing the role-checking logic directly inside the API route, let’s create a separate file for that. Create a src/lib/authorization.ts file and add the following to it:
import { adminFirestore } from "./firebase-admin";
export type UserRole = "admin" | "user" | "blocked";
const ALLOWED_ROLES: UserRole[] = ["admin", "user"];
export async function getUserRole(uid: string): Promise<UserRole | null> {
const doc = await adminFirestore.collection("users").doc(uid).get();
if (!doc.exists) return null;
const data = doc.data() as { role: UserRole };
return data.role ?? null;
}
export async function isUserAuthorized(uid: string): Promise<boolean> {
const role = await getUserRole(uid);
if (!role) return false;
return ALLOWED_ROLES.includes(role);
}
In the code above, getUserRole takes a Firebase UID, looks up the corresponding document in the users Firestore collection, and returns the role stored in it. If no document exists for that UID, it returns null, meaning that the user has never been granted access.
Also, isUserAuthorized calls getUserRole and checks whether the result is in the ALLOWED_ROLES array. Only “admin” and “user” pass this check. A “blocked” role or a missing document both return false.
Next, let’s add the authorization check to our API route. Open the src/app/api/chat/route.ts file and update the code as shown below:
import { mastra } from "@/mastra";
import { NextResponse } from "next/server";
import { adminAuth } from "@/lib/firebase-admin";
import { isUserAuthorized } from "@/lib/authorization";
export async function POST(req: Request) {
const authHeader = req.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const idToken = authHeader.split("Bearer ")[1];
let decodedToken;
try {
decodedToken = await adminAuth.verifyIdToken(idToken);
} catch {
return NextResponse.json(
{ error: "Invalid or expired token" },
{ status: 401 },
);
}
const authorized = await isUserAuthorized(decodedToken.uid);
if (!authorized) {
return NextResponse.json(
{ error: "You do not have access to this agent." },
{ status: 403 },
);
}
const { message } = await req.json();
if (!message) {
return NextResponse.json({ error: "Message is required" }, { status: 400 });
}
const agent = mastra.getAgent("weatherAgent");
const result = await agent.generate([{ role: "user", content: message }]);
return NextResponse.json({ response: result.text });
}
We updated the code to follow a sequential flow. First, we verify the token (authentication), then checking the role (authorization). If either check fails, an error is returned.
Notice how the message is only read after both checks pass. There is no reason to retrieve the message if the user is not authenticated or not authorized.
Now, let’s update our Mastra server instance. We want to inform it about our role system as well, so that even direct calls to Mastra’s server go through the same authorization logic.
Open the src/mastra/index.ts file and update it as shown below:
import { Mastra } from "@mastra/core/mastra";
import { PinoLogger } from "@mastra/loggers";
import { LibSQLStore } from "@mastra/libsql";
import {
Observability,
DefaultExporter,
CloudExporter,
SensitiveDataFilter,
} from "@mastra/observability";
import { weatherWorkflow } from "./workflows/weather-workflow";
import { weatherAgent } from "./agents/weather-agent";
import { MastraAuthFirebase } from "@mastra/auth-firebase";
import { isUserAuthorized } from "@/lib/authorization";
export const mastra = new Mastra({
workflows: { weatherWorkflow },
agents: { weatherAgent },
storage: new LibSQLStore({
id: "mastra-storage",
url: "file:./mastra.db",
}),
server: {
auth: new MastraAuthFirebase({
authorizeUser: async (user) => {
return isUserAuthorized(user.id);
},
}),
},
logger: new PinoLogger({
name: "Mastra",
level: "info",
}),
observability: new Observability({
configs: {
default: {
serviceName: "mastra",
exporters: [
new DefaultExporter(),
new CloudExporter(),
],
spanOutputProcessors: [
new SensitiveDataFilter(),
],
},
},
}),
});
Let’s pay attention to this block of code:
export const mastra = new Mastra({
...
server: {
auth: new MastraAuthFirebase({
authorizeUser: async (user) => {
return isUserAuthorized(user.uid);
},
}),
},
///
});
The authorizeUser function receives the decoded Firebase token after verification. Then user.uid is passed to the same isUserAuthorized function we used earlier in the API route. This keeps the role logic in one place and enforces it consistently across both entry points.
Let’s update the chat page to acknowledge authorization. Open the src/app/page.tsx file and update the code as shown below:
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { User } from "firebase/auth";
import { signOut } from "firebase/auth";
import { auth } from "@/lib/firebase";
import { useAuth } from "@/context/AuthContext";
export default function Home() {
const { user, loading } = useAuth();
const router = useRouter();
const userRef = useRef<User | null>(null);
const [message, setMessage] = useState("");
const [response, setResponse] = useState("");
const [fetching, setFetching] = useState(false);
const [forbidden, setForbidden] = useState(false);
useEffect(() => {
userRef.current = user;
}, [user]);
useEffect(() => {
if (!loading && !user) {
router.push("/login");
}
}, [user, loading, router]);
const handleSubmit = async () => {
if (!message.trim() || !userRef.current) return;
setFetching(true);
setResponse("");
const token = await userRef.current.getIdToken(true);
const res = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ message }),
});
if (res.status === 403) {
setForbidden(true);
setFetching(false);
return;
}
const data = await res.json();
setResponse(data.response ?? data.error);
setFetching(false);
};
const handleSignOut = async () => {
await signOut(auth);
router.push("/login");
};
if (loading || !user) return null;
return (
<main className="flex min-h-screen flex-col items-center justify-center p-8 gap-6">
<div className="w-full max-w-lg flex justify-between items-center">
<h1 className="text-2xl font-bold">Weather Agent</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-500">{user.email}</span>
<button
onClick={handleSignOut}
className="text-sm text-red-500 hover:underline"
>
Sign out
</button>
</div>
</div>
{forbidden && (
<div className="w-full max-w-lg border border-red-300 rounded p-4 bg-red-50 text-sm text-red-700">
You do not have access to this agent. Please contact the
administrator.
</div>
)}
<textarea
className="w-full max-w-lg border rounded p-3 text-sm"
rows={3}
placeholder="Ask about the weather anywhere..."
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button
onClick={handleSubmit}
disabled={fetching}
className="bg-blue-600 text-white px-6 py-2 rounded disabled:opacity-50"
>
{fetching ? "Thinking..." : "Send"}
</button>
{response && (
<div className="w-full max-w-lg border rounded p-4 bg-gray-50 text-sm">
{response}
</div>
)}
</main>
);
}
Here, we added a new forbidden state that is set to true when the user is unauthorized. In that case, a message is rendered on the screen. The user then understands what happened and what to do next (contact whoever manages access).
To test this, in the Firestore database we created earlier, create a collection called users. Follow the steps below for each user you want to grant access:
string with a value of the email you want to test with. Role should also be of type string, and its value should be either “user” or “admin,” or “blocked” if you want to deny a user access.
For this demo, we are manually adding users to Firestore to keep things simple. In a real application, you would automate this by creating the user’s Firestore document programmatically right after their first sign-in.
Go to the Rules tab in Firestore and update the default rules as shown below:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.time < timestamp.date(2026, 4, 23);
}
match /users/{uid} {
allow read, write: if false;
}
}
}
This prevents any browser code from reading role data directly. The server (Admin SDK) bypasses these rules entirely, so it still works fine.
Now, you can test the application in your browser. The agent now restricts access to users with the “blocked” role.

Change the role to “admin” or “user.”

The user can now get responses to messages sent.

Agent authentication and authorization are key security mechanisms that cannot be overlooked. Authentication confirms who the user accessing an agent is, while authorization determines whether that user should be granted access.
The MastraAuthFirebase class makes the setup straightforward and allows us to add these security layers to our agent’s server easily.
In this article, we built a simple Next.js project that implements authentication and authorization across the frontend, the Next.js API, and the agent’s server. We covered the basics here, but you can explore the official Mastra documentation to build on what we covered.