DEV Community

Cover image for We Almost Shipped a Security Hole. Here's How React Context and .NET 10 Razor Pages Saved Us
Dong Atienza
Dong Atienza

Posted on

We Almost Shipped a Security Hole. Here's How React Context and .NET 10 Razor Pages Saved Us

Part 3 of the React for C#/.NET Developers series. Part 1 covered fundamentals. Part 2 covered useEffect. This one covers something nobody warns you about until it's almost too late.


I was running a React training for a group of senior .NET developers on a .NET 10 project last month.

Smart developers. Years of experience. They'd sailed through JSX, components, useState, and useEffect without much trouble.

Then someone asked a question that stopped the entire class cold.

"Sir — if React runs in the browser, doesn't that mean our data processing happens on the client side? Isn't that a security risk?"

Silence.

Then everyone slowly turned to look at me. Because they all realized at the same moment that yes — that's exactly what was happening. And we were two days away from demo.

That question led to one of the most frustrating — and ultimately most valuable — implementation challenges I've seen in a training class. And it completely changed how I teach React security to C# developers.

Here's the full story, and the solution we built.


The problem nobody mentions in React tutorials

Most React tutorials show you this flow:

// The tutorial approach
useEffect(() => {
    fetch('/api/transactions')           // calls your API
        .then(r => r.json())             // gets data
        .then(data => setTransactions(data)); // stores in state
}, []);
Enter fullscreen mode Exit fullscreen mode

Clean. Simple. Works perfectly. Ship it.

But here's what that actually means from a security perspective:

What you think happens What actually happens
Data is processed securely Data is fetched and processed in the browser
Server controls data access JavaScript in the browser controls data access
Business logic is protected Anyone can inspect network requests and responses
Sensitive calculations are safe All calculations happen client-side — visible to anyone

Coming from C#/.NET where your controllers, services, and business logic live safely on the server — this is a genuinely uncomfortable realization.

And for our .NET 10 project which dealt with financial transaction data, it wasn't just uncomfortable. It was unacceptable.


First — let's cover Context properly

Before I get to the security solution, I need to cover React Context — because it's central to how we solved the problem.

The prop drilling problem

Imagine you have a JWT token after login. You need it in your NavBar, your TransactionList, your AccountDetails, and your Dashboard components. Without Context you'd pass it like this:

// Without Context — prop drilling nightmare
<App token={token}>
    <Dashboard token={token}>
        <TransactionList token={token}>
            <TransactionRow token={token} />
        </TransactionList>
    </Dashboard>
</App>

// Every component needs token even if it doesn't use it
// Just passing it down to the next level — messy
Enter fullscreen mode Exit fullscreen mode

Sound familiar? In C# terms this is like passing a dependency through every constructor in your call chain instead of using DI.

Context fixes this exactly the way DI fixes it in C# — register once, inject anywhere.

Context is React's dependency injection

C# Dependency Injection React Context
services.AddScoped<IAuthService>() createContext(null)
services.AddScoped<AuthService>() AuthProvider component
constructor(IAuthService auth) useContext(AuthContext)
Program.cs registration <AuthProvider> wrapping App
// 1. Create the context
import { createContext, useContext, useState } from 'react';

const AuthContext = createContext(null);

// 2. Create the provider — like your DI registration
export function AuthProvider({ children }) {
    const [token, setToken] = useState(
        localStorage.getItem('jwt_token')
    );

    const login = (newToken) => {
        localStorage.setItem('jwt_token', newToken);
        setToken(newToken);
    };

    const logout = () => {
        localStorage.removeItem('jwt_token');
        setToken(null);
    };

    return (
        <AuthContext.Provider value={{ token, login, logout }}>
            {children}
        </AuthContext.Provider>
    );
}

// 3. Custom hook — like a typed service interface
export function useAuth() {
    return useContext(AuthContext);
}

// 4. Wrap your app once — like Program.cs
// main.jsx
root.render(
    <AuthProvider>
        <App />
    </AuthProvider>
);

// 5. Consume anywhere — no prop drilling
function NavBar() {
    const { token, logout } = useAuth();
    return (
        <nav>
            <span>{token ? 'Logged in' : 'Guest'}</span>
            <button onClick={logout}>Sign Out</button>
        </nav>
    );
}
Enter fullscreen mode Exit fullscreen mode

💡 Key insight: Context is React's dependency injection. The AuthProvider is your service registration. useAuth() is your constructor injection. Once you see it that way, it stops being confusing.


Back to the security problem — and how we actually solved it

The standard approach — React fetching data directly from an API — means sensitive data processing happens in the browser. For a banking dashboard dealing with transaction data, that wasn't acceptable.

My student's question led us to a hybrid architecture that took us a frustrating day and a half to implement — but when it finally worked, the relief in the room was palpable.

The solution: Razor Pages handles all sensitive data processing server-side. React Islands consume that pre-processed data through HTML data attributes. No fetch() needed. No client-side data processing.

The architecture we built

Layer Technology Responsibility
Authentication ASP.NET Core [Authorize] JWT validation — server side
Data processing Razor Pages PageModel Query DB, shape data, serialize
Data delivery HTML data-* attributes Pre-processed JSON in the page
UI rendering React Islands Read data attributes, render UI
No client API calls None needed Data already in the page

The Razor Pages side — DashboardModel.cs

The [Authorize] attribute handles authentication before the page even loads — no JWT token validation happening in JavaScript:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using DashboardProject.Data;
using System.Text.Json;

namespace DashboardProject.Pages;

[Authorize]  // ← server-side auth — React never sees unauthenticated data
public class DashboardModel : PageModel
{
    private readonly AppDbContext _db;

    // DI injection — same pattern you know from C#
    public DashboardModel(AppDbContext db) => _db = db;

    // JSON strings written into data-* attributes in the .cshtml
    // React reads them back — no fetch() needed
    public string TransactionsJson { get; private set; } = "[]";
    public string SummaryJson      { get; private set; } = "[]";

    public void OnGet()
    {
        // Query once — reuse for both serializations
        var rows = _db.Transactions.ToList();

        // Shape matches what React expects
        var txData = rows.Select(t => new
        {
            t.Id,
            accountNumber = t.AccountNumber,
            amount = double.TryParse(t.Amount, out var a) ? a : 0d,
            type   = t.Type,
            date   = t.Date,
        });

        // Summary: group by type, sum amounts
        var summary = rows
            .GroupBy(t => t.Type)
            .Select(g => new
            {
                type  = g.Key,
                total = g.Sum(t => double.TryParse(t.Amount, out var a) ? a : 0d),
                count = g.Count(),
            });

        // camelCase so JS keys match: t.type, t.amount, s.total
        var opts = new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        };

        TransactionsJson = JsonSerializer.Serialize(txData,  opts);
        SummaryJson      = JsonSerializer.Serialize(summary, opts);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Razor Page — Dashboard.cshtml

The page writes the server-processed JSON directly into HTML data attributes. React never makes an API call — it reads from the DOM:

@page
@model DashboardModel

<div
    id="transactions-island"
    data-transactions="@Model.TransactionsJson"
    data-summary="@Model.SummaryJson">
</div>

<script type="module" src="~/js/dashboard.jsx"></script>
Enter fullscreen mode Exit fullscreen mode

The React Island — reads from data attributes

No useEffect, no fetch(), no API call:

import { useState, useMemo } from 'react';
import { createRoot } from 'react-dom/client';

function TransactionsDashboard() {
    const el = document.getElementById('transactions-island');

    // Read server-processed data from data attributes
    // Data was already validated and shaped by C# on the server
    const [transactions] = useState(() =>
        JSON.parse(el.dataset.transactions)
    );
    const [summary] = useState(() =>
        JSON.parse(el.dataset.summary)
    );
    const [filter, setFilter] = useState('All');

    // useMemo from Article 2 — efficient filtering
    const displayed = useMemo(() =>
        filter === 'All'
            ? transactions
            : transactions.filter(t => t.type === filter),
        [transactions, filter]
    );

    return (
        <div>
            {/* Summary cards from server-computed data */}
            {summary.map(s => (
                <div key={s.type}>
                    <h3>{s.type}</h3>
                    <p>Total: ₱{s.total.toLocaleString()}</p>
                    <p>Count: {s.count}</p>
                </div>
            ))}

            {/* Filter buttons */}
            <button onClick={() => setFilter('All')}>All</button>
            <button onClick={() => setFilter('Deposit')}>Deposits</button>
            <button onClick={() => setFilter('Withdraw')}>Withdrawals</button>

            {/* Transaction list */}
            {displayed.map(t => (
                <div key={t.id}>
                    <span>{t.accountNumber}</span>
                    <span>{t.type}</span>
                    <span>{t.amount.toLocaleString()}</span>
                </div>
            ))}
        </div>
    );
}

// Mount the React Island
const el = document.getElementById('transactions-island');
if (el) createRoot(el).render(<TransactionsDashboard />);
Enter fullscreen mode Exit fullscreen mode

Why this approach is more secure

Standard React SPA Razor Pages + React Islands
Auth checked in JavaScript Auth checked by [Authorize] on server
Data fetched client-side Data processed server-side before page loads
Business logic in browser Business logic in C# — never leaves server
API endpoints exposed No additional API endpoints needed
JWT stored in localStorage Session managed by ASP.NET Core

⚠️ Security note: This approach doesn't mean React SPAs are insecure — they can be built securely too. But for applications dealing with sensitive financial or personal data, keeping processing server-side adds a meaningful layer of protection that C#/.NET developers will immediately recognize and trust.


When to use which approach

This isn't an either/or decision. Here's how I guide my students:

Use React SPA when Use Razor Pages + React Islands when
Public-facing UI with no sensitive data Financial or personal data processing
Real-time updates needed (WebSockets) Server-side auth is non-negotiable
Complex client-side interactions SEO matters for your pages
Separate frontend team Existing Razor Pages app adding React gradually
Mobile app consuming the same API .NET 10 project with mixed page types

In our .NET 10 project we ended up using both — React Islands on sensitive dashboard pages, pure React SPA for the less sensitive reporting pages. The architecture supports both patterns cleanly.


The moment it finally worked

After a day and a half of frustration — fighting with data attribute escaping, JSON serialization options, camelCase vs PascalCase mismatches, and React hydration timing — we got it working.

The dashboard loaded. The data appeared. The filters worked. And most importantly — opening the browser network tab showed exactly zero API calls to our sensitive transaction endpoints.

The room went quiet for a second.

Then someone said: "That's actually really clean."

And they were right. Once you get past the implementation complexity the pattern is elegant — C# does what C# is good at (server-side data processing, authentication, business logic) and React does what React is good at (rendering, interactivity, state management).

Twenty years of .NET experience finally felt like an advantage in a React project rather than baggage to overcome.


What's next in this series

Article 4 will cover AG Grid for C#/.NET developers — column definitions, filtering, sorting, and the pivot table feature that makes your manager's eyes light up. All mapped to concepts you already know from working with data grids in WinForms and WPF.

If you missed the earlier articles:


Want the complete 8-hour training?

Everything in this series plus JWT authentication, AG Grid with pivot tables,
Recharts dashboards, and the complete SecureBank build — packaged as an 8-hour
self-paced guide specifically for C#/.NET developers.

React for C#/.NET Developers — Complete Training Guide


Has your team hit the same security concern with React? How did you solve it? Drop it in the comments — I'd genuinely love to know if others took a different approach. 👇

Top comments (0)