Skip to main content
Audit ReportCompleted 2026-04-09 · Sample · 10 of 91

Core HR Hub

A curated sample showing 10 of 91 findings from an Advanced-tier audit — verified against source code with file paths, line numbers, and evidence.

Total findings
91
Verified in source · 10 shown
Critical
4
Before launch
High
34
First sprint
Medium
35
Over time
Low
18
Polish
Severity distributionWhere the 91 findings sit on the risk scale
Critical· 4High· 34Medium· 35Low· 18
Overview

Has critical issues to resolve before launch.

This is a curated sample showing 10 of 91 findings from a Advanced-tier audit of Core HR Hub, a React + TypeScript frontend backed by Supabase (PostgreSQL, Auth, Edge Functions) that provides end-to-end HR operations including employee lifecycle, leave, attendance, payroll, performance reviews, and asset management. The codebase has real strengths: consistent adoption of shadcn/Radix primitives, a generated Supabase types layer, row-level security enabled on every table, and HTML escaping in every email-sending edge function.

Beyond the criticals, this sample covers authentication token lifetime, CORS wildcards, a disabled CI workflow, accessibility gaps in form labels, lazy-loading, design token adoption, error boundaries, and unused web fonts.

Must fix before launch

Four critical issues. Two shown.

Both involve access-control rules enforced only in client-side code. Both are fixable in a few hours of server-side work.

Critical path to launch

Resolve these first. Everything else can ship with a plan.

  • #01
    Blocked employees can keep using the app because the block is only enforced in client-side JavaScript.
    Jump
  • #02
    Employees can self-approve their own leave requests by posting a status of 'approved' directly to the database.
    Jump
+ 2 more critical issues in the full audit02 / 04
Quick wins

Five fixes. Most under an hour.

Address four high-severity issues with changes that touch one or two files.

What’s working well

The foundation is solid.

Consistent primitives, row-level security enabled across the board, and HTML escaping in every email — real strengths to build on.

Consistent Radix + shadcn/ui baseline

Every dialog, dropdown, and form primitive is built on Radix, giving the app free focus trapping, keyboard navigation, and a predictable component surface. Only one raw HTML button exists in the entire codebase.

Row-level security enabled on every table

All 30+ tables enable RLS and several critical ones (employees, performance_reviews) use FORCE ROW LEVEL SECURITY. SECURITY DEFINER helper functions (has_role, is_admin_or_hr, get_my_employee_id) centralize the authorization checks.

Generated Supabase types layer

src/integrations/supabase/types.ts is the single source of truth for database shapes. Most hooks use it correctly; the places that don't are called out as findings.

Email template HTML escaping is consistent

Every edge function that sends mail pipes user-controlled values through an escapeHtml helper before interpolation, closing the most common XSS-via-email surface.

Architecture map

How your app is structured

A visual breakdown of how your app is built, including components, services, and data flow.

Core HR Hub is a React 18 + TypeScript single-page app backed by Supabase (PostgreSQL 15, Supabase Auth for email/password login, and 13 Deno-based edge functions for email and push notifications). The frontend talks directly to Supabase via the supabase-js client with no intermediate application backend. Business logic lives inside React hooks, in Postgres functions and triggers, and in the RLS policies attached to every table. The app supports four roles (admin, hr, manager, employee) through a hierarchical user_roles table and ships as a Progressive Web App. The data model is a single-tenant HR domain with ~30 tables spanning employees, leaves, attendance, payroll, performance reviews, assets, documents, notifications, and audit logs.

Findings · 10 shown

Verified against source code.

Every finding includes file path, line number, code evidence, and a concrete fix.

#01critical Security

Blocked employees can still use the app

src/contexts/AuthContext.tsx:65-85Moderate (3-4 files)
What

Account blocking is enforced only in client-side JavaScript. When an admin marks a user blocked, nothing on the server actually prevents that user from using the app — the check runs after Supabase has already issued a valid JWT, and the local sign-out it performs only clears browser storage.

Why it matters

A blocked employee, contractor, or former staff member can keep using an already-issued token by calling the Supabase REST or edge function APIs directly, or by patching the client-side check out of a built bundle. Blocking is the primary offboarding control, so if it doesn't work, offboarding doesn't work.

Evidence
src/contexts/AuthContext.tsx:65-85
if (data.user) {
  const { data: profile } = await supabase
    .from('profiles').select('blocked').eq('id', data.user.id).single();
  if (profile?.blocked) {
    await supabase.auth.signOut({ scope: 'local' });
    return { error: new Error('Your account has been blocked...') };
  }
}
 
// No RLS policy gates SELECT/INSERT/UPDATE/DELETE on profiles.blocked = false.
Suggested fix

Enforce blocked state server-side in two layers: (1) add a Postgres trigger or Supabase auth hook that deletes refresh tokens and sessions when profiles.blocked flips to true; (2) add a SECURITY DEFINER helper is_not_blocked(auth.uid()) and call it from every sensitive RLS policy alongside existing role checks. Also remove the scope: 'local' option from signOut so server sessions are revoked.

#02critical Security

Employees can self-approve their own leave requests

supabase/migrations/20251215061807_f053c66f-f682-4322-a2d0-2a03d07b754e.sql:65-68Straightforward (2-3 files)
What

The 'Create own leave requests' policy only checks that the employee is inserting a row for themselves. It places no constraint on the status column, so an employee can POST a leave request with status='approved' directly via the Supabase REST API, skipping manager approval entirely.

Why it matters

Leave requests feed into attendance, payroll calculations, and accrual balances. An attacker can take unapproved time off, have the system log it as manager-approved, and affect payroll and leave reporting.

Evidence
supabase/migrations/…_f053c66f.sql:65-68
CREATE POLICY "Create own leave requests"
ON public.leave_requests FOR INSERT
WITH CHECK (employee_id = get_my_employee_id());
 
-- No constraint like AND status = 'pending'.
-- A POST with {"status": "approved"} succeeds.
Suggested fix

Tighten the WITH CHECK to WITH CHECK (employee_id = get_my_employee_id() AND status = 'pending' AND reviewed_by IS NULL AND reviewed_at IS NULL). Add a BEFORE UPDATE trigger that blocks employees from changing status, reviewed_by, or reviewed_at on their own rows.

#03high Security

Authentication tokens effectively never expire

.env (committed) and Supabase project JWT configTrivial (1 file)
What

The anon JWT committed to .env carries exp 2080871957, which decodes to 2 January 2036 — roughly ten years of validity. Supabase projects that issue anon tokens with these expiries typically mint user access tokens with matching long lifetimes.

Why it matters

A laptop stolen today is still able to read payroll in 2035. There is no revocation mechanism operators can pull without rotating the entire JWT secret, which would invalidate every user at once. Combined with the client-side-only blocking (Finding 1), a stolen token is valid for a decade.

Evidence
.env
// Decoded middle segment of committed anon JWT:
{
  "iss": "supabase",
  "role": "anon",
  "iat": 1765295957,
  "exp": 2080871957   // → 2036-01-02
}
Suggested fix

In the Supabase dashboard, regenerate the project API keys with a sane expiry (Supabase defaults are fine) and set JWT expiry to 1 hour with refresh-token rotation enabled. Remove the old key from git history or rotate the JWT secret so old tokens stop verifying.

#04high Security

Edge functions allow requests from any origin

All 13 files under supabase/functions/*/index.tsStraightforward (2-3 files)
What

Each edge function sets Access-Control-Allow-Origin: '*' in its CORS headers. With a wildcard, any website a user visits can call these functions from the user's browser. Browsers won't send cookies with *, but Authorization headers carried via JavaScript are still sent.

Why it matters

invite-employee and the notification functions accept Bearer tokens from the caller. With a wildcard origin, an attacker who gets a user to visit their site and has a leaked token (from a logged console, an extension, or a shared device) can make authenticated calls from their origin. The wildcard also removes one mitigation layer against phishing pages probing these endpoints.

Evidence
supabase/functions/*/index.ts
const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info',
};
Suggested fix

Replace * with a comparison against an ALLOWED_ORIGINS env var (production domain plus preview deploys). Echo the matching origin back in the response header only when it matches the allow list. Generalize the same approach already used by ALLOWED_REDIRECT_HOSTS in invite-employee.

#05high Code Quality

CI workflow never actually runs

.github/workflows/ci.yml:3-7Trivial (1 file)
What

The CI workflow's trigger is configured against the literal placeholder branch name [BRANCH-NAME] rather than real branch names. Lint, typecheck, and build never execute on any push or pull request.

Why it matters

The team believes CI is enforcing lint and typecheck, but it is not running at all. Bugs that lint or tsc --noEmit would have caught are shipping straight to production.

Evidence
.github/workflows/ci.yml
on:
  push:
    branches: [BRANCH-NAME]
  pull_request:
    branches: [BRANCH-NAME]
Suggested fix

Replace [BRANCH-NAME] with [main, main-staging] (or whichever long-lived branches exist). Add a status badge to the README so a broken CI wiring is immediately visible.

#06high Accessibility

Form labels are visually present but not linked to their inputs

src/components/profile/LeaveRequestForm.tsx:142-247, src/pages/Leaves.tsx:604-606, src/pages/Onboarding.tsx:1182, and most pages using <Label> without htmlForModerate (3-4 files)
What

Many forms use the shadcn Label component without htmlFor, paired with Select, Popover, or Textarea components that have no id. The visual label is there, but a screen reader following the input will not announce it.

Why it matters

Screen reader users hear 'edit, blank' instead of 'Reason, edit, blank.' This is a WCAG 2.1 AA violation under 'Info and Relationships' (1.3.1) and 'Labels or Instructions' (3.3.2), and it applies to most leave, onboarding, and profile forms.

Evidence
src/components/profile/LeaveRequestForm.tsx:142
<div className="space-y-2">
  <Label>Leave Type</Label>
  <Select value={leaveTypeId} onValueChange={setLeaveTypeId}>
    <SelectTrigger>
      {/* no id linking label to trigger */}
Suggested fix

Adopt react-hook-form plus the shadcn Form / FormField / FormLabel / FormControl primitives, which already wire aria-describedby, aria-invalid, and id linking. At minimum, give every input an id and pass it to the matching <Label htmlFor=...>.

#07high Performance

Routes are not lazy-loaded, so the entire app ships in the initial bundle

src/App.tsx:11-37Straightforward (2-3 files)
What

Every page is a synchronous top-level import. There is no React.lazy/Suspense, no Vite manualChunks split, and no rollup output config. jsPDF + jspdf-autotable (~150 KB gzipped), recharts (~90 KB gzipped), and 25+ Radix primitives are all eagerly pulled into the landing-page bundle.

Why it matters

Marketing visitors load the entire authenticated app — payroll, reports, performance reviews — on first paint. On a 4G connection this is several seconds of wasted time-to-interactive.

Evidence
src/App.tsx
import Payroll from './pages/Payroll';
import Reports from './pages/Reports';
import Performance from './pages/Performance';
// + 22 more synchronous imports
Suggested fix

Convert each protected route to const Payroll = lazy(() => import('./pages/Payroll')) and wrap routes in <Suspense>. Lazy-import jsPDF inside payslipPdfGenerator.ts (const { default: jsPDF } = await import('jspdf')) so it is not pulled into unrelated chunks.

#08medium UX

Hardcoded Tailwind palette colors bypass design tokens in 38+ files

src/components/** and src/pages/**Significant (5+ files)
What

A grep for text-(red|blue|green|...)-[0-9] finds 153 occurrences in 38 files, and bg-(color)-[0-9] finds 92 in 29 files. These bypass the token system in src/index.css. Charts, leave types, calendar event types, role badges, payroll deltas, and rating stars all hardcode raw Tailwind palette values.

Why it matters

Theming, dark mode, and brand updates all break. The dark mode block in src/index.css only updates semantic tokens, so any view that hardcodes text-emerald-600 looks wrong in dark mode.

Evidence
src/pages/Leaves.tsx
const leaveTypeStyles = {
  Annual: "bg-emerald-500/10 text-emerald-600",
  Sick:   "bg-rose-500/10 text-rose-600",
  Casual: "bg-sky-500/10 text-sky-600",
};
Suggested fix

Add semantic tokens for status (success, warning, danger, info) and a small palette of category tokens for leave types. Replace raw palette values with token references. Current adoption rate is roughly 60% tokens / 40% raw palette.

#09high Code Quality

No top-level error boundary; one render error blanks the whole app

src/App.tsxStraightforward (2-3 files)
What

No React ErrorBoundary wraps the route tree. A grep across src/ for ErrorBoundary returns zero matches. Any uncaught render error in any page (a missing field on a Supabase row, a date parse failure, an undefined chart series) will unmount the entire app.

Why it matters

Users hit a blank white screen with no recovery action. The error is not reported anywhere (no Sentry, no telemetry), so HR users on critical workflows silently hit dead screens with no diagnostic to send back.

Evidence
src/**
$ grep -r 'ErrorBoundary' src/
# (no matches)
Suggested fix

Add a top-level ErrorBoundary (react-error-boundary or a custom class component) wrapping Routes in App.tsx, with a fallback that shows a friendly message, a reload button, and a link back to the dashboard. Add a second boundary inside DashboardLayout so a single broken page does not kill the shell.

#10low UX

Eight web fonts loaded; only three are actually used

src/index.css:1-8Trivial (1 file)
What

The CSS imports Poppins, Merriweather, JetBrains Mono, Space Grotesk, Lora, Space Mono, and Inter from Google Fonts. tailwind.config.ts only uses Inter, Lora, and Space Mono. The other 5 are fetched on every page load and discarded.

Why it matters

Roughly 600 KB of unused web fonts on first paint, slowing down landing page LCP.

Evidence
src/index.css
@import url('…Poppins…');
@import url('…Merriweather…');
@import url('…Space Grotesk…');
@import url('…Inter…');
@import url('…Lora…');
@import url('…Space Mono…');
Suggested fix

Remove the unused 5 font imports. Move font loading from @import (render-blocking) to <link rel='preconnect'> + <link rel='preload'> in index.html for the fonts you actually use.

Performance

Four changes that move the needle.

The app has not been performance-tuned for production. The four highest-impact wins are: lazy-load routes so the marketing site does not ship the entire authenticated app, memoize the AuthContext value to stop the cascade-rerender storm, raise React Query's default staleTime so queries do not refire on every mount, and move the payslip and bulk export PDF generation off the main thread. These four changes are mostly mechanical and would noticeably improve initial JS payload, time-to-interactive, scroll smoothness, and dashboard responsiveness.

Accessibility

Strong baseline, four gaps.

The codebase has a strong baseline from consistent shadcn/Radix adoption: dialogs trap focus, dropdown menus support keyboard navigation, the Toast viewport carries aria-live, and forms use real input and button elements. Skeleton loaders are wired into most data fetches, and EmployeeTable correctly labels its bulk-action checkboxes. The login screen labels its email and password fields properly.

Scalability

Fine at pilot. Breaks at 1,000–5,000 users.

Core HR Hub works comfortably at small scale — roughly 200 employees, 5,000 leave requests, 50,000 attendance rows — because the dataset fits in browser memory and Postgres sequential-scans small tables instantly. At that scale, the missing foreign-key indexes, the unbounded list queries, the sequential push notification dispatch, and the listUsers-on-every-invite pattern are all invisible. Pilot deployments will not catch any of these.

Production readiness roadmap

Four phases. Clear order.

Start with the two criticals. Security hardening next. Quality work in parallel once the launch path is clear.

1

Phase 1Critical access-control blockers

These two issues are access-control failures that will cause real harm if shipped as-is.

#01Enforce account blocking server-side via RLS helper and session revocation, and switch signOut to global scope.Moderate (3-4 files)
#02Tighten the leave_requests INSERT policy to force status='pending' and block employees from changing status on their own rows.Straightforward (2-3 files)
2

Phase 2Security hardening

Items that do not block the critical path but should be done before inviting real HR customers, including token lifetime, CORS, and the CI quality gate.

#03Rotate Supabase API keys and set a realistic JWT expiry (1 hour with refresh-token rotation).Trivial (1 file)
#04Replace wildcard CORS with an ALLOWED_ORIGINS allow list in every edge function.Straightforward (2-3 files)
#05Fix the CI workflow branch trigger so lint and typecheck actually run.Trivial (1 file)
3

Phase 3Accessibility, performance, and quality

The items that keep the app usable for all users and performant as the dataset grows.

#06Adopt the shadcn Form primitives across all forms so labels and error states are wired properly.Moderate (3-4 files)
#07Lazy-load each protected route and dynamically import jsPDF.Straightforward (2-3 files)
#09Wrap the route tree in a top-level ErrorBoundary with a friendly fallback.Straightforward (2-3 files)
4

Phase 4Design system and cleanup

Lower-severity UX and code-quality items that pay back over months rather than days.

#08Migrate the 38+ files using raw Tailwind palette shades to semantic design tokens.Significant (5+ files)
#10Remove the unused 5 font imports and switch the remaining fonts to preload.Trivial (1 file)
Next steps

A clear picture of where you stand.

If you’d like help prioritizing or implementing these changes, we’re here to help.

You’ve seen the sample

What’s hiding in yours?

Ship with confidence. Know exactly where you stand. An Essential audit surfaces the ones that matter. Advanced goes deep.

Get Your Audit