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:
- Open browser DevTools.
- Access the React component tree.
- Modify the role context value from
viewertoadmin. - 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.

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 |

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.