Secure Next.js FinTech Client Portal with Role-Based Access Control

By Oleh FrozPublished on February 22, 2026

A FinTech startup discovered that their off-the-shelf React template had a critical vulnerability: users could escalate their own permissions by modifying a client-side role variable in the browser console. I built a ground-up Next.js client portal with server-enforced RBAC, encrypted JWT session management, and database-level audit triggers — achieving a perfect 100/100 Lighthouse Security and Best Practices score with zero additional request overhead.

The Vulnerability That Forced a Rebuild

The client's existing portal was built on a popular React admin template purchased for $49. It stored the user's role — viewer, manager, or admin — in a React context provider populated from a single unprotected API call at login. The role value was never re-verified on subsequent requests.

The attack surface was elementary:

  1. Open browser DevTools.
  2. Access the React component tree.
  3. Modify the role context value from viewer to admin.
  4. Access the admin panel, approve pending withdrawals, and modify account balances.

This was not a theoretical vulnerability. The client discovered it when a QA tester accidentally triggered an admin action from a test viewer account. An internal audit confirmed that no server-side role verification existed on any API endpoint.

Templates built for visual demos carry none of the security infrastructure required for financial data.

Server-Enforced Role Architecture

The rebuilt system enforces roles at three distinct layers, each independent of the others:

Layer 1: Supabase RLS Policies. Every database table carrying financial data has row-level policies that check the user's role from their JWT claims. A viewer role physically cannot query the transactions table for records they're not authorized to see — the database returns an empty set.

Layer 2: Next.js Middleware. A centralized middleware function intercepts every request before it reaches a route handler:

// middleware.ts — Zero-trust request verification
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { createServerClient } from '@supabase/ssr';

const ROLE_ROUTES: Record<string, string[]> = {
  '/dashboard/admin': ['admin'],
  '/dashboard/approvals': ['admin', 'manager'],
  '/dashboard/accounts': ['admin', 'manager', 'viewer'],
  '/api/transfers/approve': ['admin'],
  '/api/accounts/update-limits': ['admin', 'manager'],
};

export async function middleware(request: NextRequest) {
  const response = NextResponse.next();

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  const userRole: string = user.app_metadata?.role ?? 'viewer';
  const pathname = request.nextUrl.pathname;

  for (const [route, allowedRoles] of Object.entries(ROLE_ROUTES)) {
    if (pathname.startsWith(route) && !allowedRoles.includes(userRole)) {
      return NextResponse.redirect(new URL('/dashboard', request.url));
    }
  }

  return response;
}

Layer 3: Server Action Validation. Every mutation (fund transfer, limit change, account modification) runs as a Next.js Server Action that independently re-verifies the user's session and role. The middleware is not trusted as the sole gate — defense in depth.

Encrypted Session Management

Sessions use Supabase Auth's PKCE flow with HTTP-only cookies. The JWT is never exposed to client-side JavaScript. The access_token and refresh_token are stored as HttpOnly, Secure, SameSite=Lax cookies that the browser sends automatically but JavaScript cannot read.

Diagram of the encrypted session flow from login through PKCE exchange to HTTP-only cookie storage

Token refresh happens transparently via Supabase's built-in refresh mechanism. If a refresh fails (token revoked, user deactivated), the middleware redirects to login on the next request. There is no client-side token management code.

Database Audit Trail

Every write operation to financial tables triggers a PostgreSQL function that inserts a record into the audit_log table:

// supabase/migrations/005_audit_triggers.sql
CREATE TABLE audit_log (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id uuid NOT NULL,
  user_role text NOT NULL,
  action text NOT NULL,
  table_name text NOT NULL,
  record_id uuid,
  old_data jsonb,
  new_data jsonb,
  ip_address inet,
  created_at timestamptz DEFAULT now()
);

CREATE OR REPLACE FUNCTION log_financial_change()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO audit_log (user_id, user_role, action, table_name, record_id, old_data, new_data)
  VALUES (
    auth.uid(),
    (auth.jwt() ->> 'role'),
    TG_OP,
    TG_TABLE_NAME,
    COALESCE(NEW.id, OLD.id),
    to_jsonb(OLD),
    to_jsonb(NEW)
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

The audit log is append-only. No user role — including admin — has DELETE or UPDATE permissions on the audit_log table. This creates a tamper-proof record of every financial state change.

Security Audit Results

| Security Vector | React Template | Next.js RBAC Build | |---|---|---| | Role Verification | Client-side only | 3-layer server enforcement | | Token Storage | localStorage (XSS vulnerable) | HTTP-only cookies | | API Endpoint Protection | None | Middleware + Server Action re-verification | | Audit Trail | None | Append-only PostgreSQL trigger log | | Lighthouse Best Practices | 72/100 | 100/100 |

Lighthouse best practices audit showing a perfect 100/100 score on the FinTech portal

Ownership and Compliance

The client holds the repository, the Supabase project, and the Vercel deployment. The audit log architecture provides a foundation for SOC 2 Type II compliance evidence collection. The codebase contains zero third-party analytics scripts or tracking pixels — only the client decides what data leaves their infrastructure.


Skip the technical debt. Let a senior engineer build your core architecture from scratch. View my custom Next.js engineering tiers on Fiverr.

    Secure Next.js FinTech Client Portal with Role-Based Access Control | Froz | Froz Web Engineering