CASE STUDY // 01

Warden Admin Panel

An internal-tools admin panel engineered around a single principle - permissions are checked on the server, always. Self-rolled auth, granular RBAC, and audit logging built to convince a senior reviewer this is real, shippable software.

Next.jsTypeScriptExpressPrismaPostgreSQLZodTanStack QueryTailwindArgon2idDocker
Warden Admin Panel screenshot 1 of 6
1 / 6

THE CHALLENGE

Authorization You Can Actually Trust

Most admin panels lean on a BaaS for auth and gate the UI with role strings - which looks fine until you realize the client is deciding what it's allowed to do. The real challenge was building an access-control system that a security reviewer would trust: authorization enforced on the server for every request, granular permissions rather than role checks, credential handling that resists timing and enumeration attacks, and an audit trail that proves who changed what and when - all without hiding the mechanics behind a third-party service.

ENGINEERING RESPONSE

Server-Authoritative RBAC on a Layered Monolith

key

Self-Rolled Session Auth

Argon2id password hashing, opaque server-side sessions stored in Postgres, and httpOnly + Secure + SameSite cookies - no tokens in localStorage, ever. Sessions are revocable server-side, so a password change or reset can invalidate stolen cookies instantly.

verified_user

Permission-Based Authorization

An authorize('orders:write') middleware gates every protected route against granular permission keys resolved from the user's roles - never against role strings. UI hiding/disabling is treated as UX only; the API is the single source of truth.

hub

Shared-Schema Modular Monolith

Eight domain modules (auth, users, roles, customers, subscriptions, invoices, analytics, audit), each layered routes → controller → service → repository. Zod schemas live in a shared package and validate on both the API and the web forms - one contract, no drift.

INTERNAL ARCHITECTURE

Modular Monolith - Layered per Moduleroutes → controller → service → repository

API LAYER
APPLICATION LAYER
DOMAIN LAYER
DATA ACCESS LAYER
CROSS-CUTTING
Primary Flow
React Flow mini map

INTERACTIVE ARCHITECTURE DIAGRAM - DRAG · ZOOM · PAN · HOVER FOR DETAILS

Chose a modular monolith with strict per-module layering over microservices - clear domain boundaries and testability without the operational tax of distributed infra for one app and one database.

DEEP DIVE

shield

Permissions, Not Roles

Roles (Admin / Manager / Viewer) are just bundles of permissions. The gate checks the atomic permission - 'invoices:delete' - so a role's capabilities can change without touching a single route. The difference is visibly enforced in both API responses and UI affordances.

timer

Timing-Safe, Enumeration-Resistant Login

A missing account still runs a verify against a pre-computed dummy Argon2id hash, so the response time matches the real path and attackers can't enumerate which emails exist. Password-reset requests always return 204 for the same reason.

history

Audit Log as a First-Class Citizen

Every sensitive mutation writes an audit entry (actor, action, entity, before/after JSON) inside the same transaction as the change itself - so the record and its proof commit or roll back together. Logins, lockouts, and session revocations are all captured.

cookie

Cross-Origin httpOnly Sessions

The Vercel-hosted frontend and a separate API host mean the session cookie is SameSite=None + Secure in production, falling back to Lax over http locally. A lightweight Next proxy does a cheap presence redirect - defense in depth, never the real gate.

THE HARD PARTS

The Details Security Reviewers Look For

Closing the Account-Enumeration Gaps

The obvious login is a leak: real accounts run Argon2 (slow), missing ones return instantly (fast), and that gap reveals valid emails. Solved by verifying against a throwaway Argon2id hash on the no-account path, returning one generic error for every failure, and making password-reset responses indistinguishable whether or not the email exists.

Session Lifecycle on Credential Change

Changing a password can't silently leave old sessions valid. A password change atomically revokes every OTHER session but keeps the current one; a reset revokes ALL sessions. Both happen in a transaction alongside the hash update and audit write, so there's no window where an old cookie still works.

Auditability Without Partial Writes

An audit log that can drift from reality is worse than none. By threading the same Prisma transaction client through both the domain write and writeAudit(), a failure anywhere rolls back the audit entry too - the log can never claim something happened that didn't.

8

DOMAIN MODULES

4-Layer

MODULE ARCHITECTURE

0

TOKENS IN LOCALSTORAGE

100%

SERVER-SIDE AUTHZ

INFRASTRUCTURE & DEPLOYMENT

Environment Parity and a Gated Pipeline

One-Command Local Stack

docker-compose brings up the API and web app together, both connecting to an external managed Postgres instance via Prisma - local development mirrors production without running the database itself in a container.

CI Gate on Every PR

GitHub Actions runs lint → typecheck → test → build, with Prisma migrations applied against the external Postgres database. Nothing merges red.

Integration Tests Against a Real Postgres Database

Vitest suites test the auth flow, a permission-denied case, and critical CRUD end-to-end against the external Postgres instance - the database is never mocked, so the tests exercise real query and transaction behavior.

Versioned Prisma Migrations

All schema changes ship as versioned migrations in the repo; the database is never hand-edited. A seed script provisions demo data and the three role logins.

Split Deploy: API on EC2, Web on Vercel

The Express API runs on a shared EC2 host while the Next.js frontend deploys to Vercel - driving the cross-origin, SameSite=None cookie design rather than assuming same-origin convenience.

SYSTEM ARCHITECTURE

Warden Admin InfrastructureSplit Deploy + Blue-Green

INFRASTRUCTURE
APPLICATION
DATA LAYER
CI/CD
Primary Flow
React Flow mini map

INTERACTIVE INFRASTRUCTURE DIAGRAM - DRAG · ZOOM · PAN · HOVER FOR DETAILS

TRADE-OFFS

swap_horiz

Self-rolled session auth over Auth0/Clerk - more responsibility, but it demonstrates the security fundamentals a BaaS hides

swap_horiz

Server-side sessions over stateless JWTs - a DB lookup per request buys instant, reliable revocation

swap_horiz

Granular permissions over role checks - more rows to seed, but authorization that survives changing requirements

swap_horiz

Modular monolith over microservices - clear boundaries without distributed-systems overhead for one app

swap_horiz

Money stored as integer cents over floats - no rounding drift in billing math

swap_horiz

Kept the repository layer even on thin CRUD for consistency, accepting some pass-through boilerplate

KNOWN LIMITATIONS

What This Implementation Doesn't Cover

warning

No In-Session Permission Refresh

If an admin changes a role's permissions mid-session — say, stripping 'invoices:delete' from Manager — sessions already in progress won't reflect that until their next request resolves the user's roles from the database. For a low-risk internal tool the window is acceptable, but it is a known gap.

warning

Account Lockout Has No Auto-Expiry

The lockout mechanism has no cooling-off timer. A locked account stays locked until an Admin explicitly unlocks it, meaning a misconfiguration or a targeted lockout attack against an admin account requires a human to resolve rather than simply waiting out a penalty period.

warning

Audit Log Stores Full Entity Snapshots Without Truncation

Every sensitive mutation writes the full before/after entity JSON. There is no field-level diffing, no size cap, and no truncation strategy — a change to a single field on a large entity writes the entire object twice. Under heavy mutation traffic this grows the audit table quickly.

warning

Session Table Is Never Actively Pruned

Expired sessions are validated and rejected on use but are never deleted by a background job or scheduled cleanup. In a test environment with frequent logins, or under sustained failed-auth attempts, the sessions table accumulates dead rows indefinitely until manual or scheduled cleanup is added.

RETROSPECTIVE

Trust the Server, Never the Client.

The lesson that ran through every module was that security lives in the unhappy path - the missing account, the stolen cookie, the reset link replayed twice, the audit write that must not outlive its transaction. Building auth from primitives instead of importing a black box forced me to reason about each of those cases explicitly, and that's exactly the judgment this project exists to show.