Security Analysis & Architecture Documentation
QuickVote is a real-time web-based polling application designed for live voting in meetings, classrooms, and presentations. This threat model identifies security risks across the application's architecture, including the React SPA frontend, Supabase backend-as-a-service, real-time WebSocket communications, image slide uploads via Supabase Storage, a separate presentation window for projected content, session templates, and team-based voting with per-team result filtering.
Single Page Application (SPA) with real-time features
Supabase (PostgreSQL + Realtime)
Anonymous JWT-based
Vercel (Static CDN)
The following diagram illustrates the main components, data flows, and trust boundaries in the QuickVote system.
| Component | Technology | Security Role |
|---|---|---|
| Admin Dashboard | React 19, TypeScript | Session management, question control, vote monitoring |
| Participant View | React 19, TypeScript | Vote submission, real-time updates |
| State Management | Zustand | Client-side state, no sensitive data persistence |
| Real-time Hooks | Supabase JS Client | WebSocket subscriptions, presence tracking |
| Password Gate | sessionStorage | Client-side only (bypassable) |
| Presentation View | React 19, TypeScript | Standalone projection window, no authentication required |
| Sequence Manager | React 19, dnd-kit | Unified slide/batch ordering |
| Image Uploader | browser-image-compression | Client-side image compression before Storage upload |
| Session Template Panel | React 19, TypeScript | Save/load session blueprints from Supabase |
| Team Components | React 19, TypeScript | Team picker, badges, filter tabs, QR grid — team assignment at vote time |
| Template Editor | React 19, dnd-kit | Visual session builder with drag-and-drop ordering |
| Service | Purpose | Security Controls |
|---|---|---|
| PostgreSQL | Data persistence | RLS policies, parameterized queries |
| Realtime | WebSocket events, presence | JWT authentication required |
| Auth | Anonymous authentication | JWT token generation |
| Storage | Image file hosting | RLS policies enforce path-based access for authenticated users |
Admin → Create Session → DB Insert → Returns admin_token → Redirect to /admin/{token}
Scan QR/URL → /session/{id} → Anonymous Auth → Realtime Subscribe → Presence Track
Participant Vote → Upsert to DB → Postgres Changes → Admin receives update
Admin Action → DB Update → Broadcast Event → All Participants receive
Admin → Compress Image → Upload to Storage → Create session_item → Display in projection
Admin navigates → Broadcast event → Presentation window receives → Display updates
Participant joins lobby → Selects team → team_id stored in Zustand → Sent with each vote upsert → Admin filters results by team
Admin tokens are UUIDs in URLs. If an attacker obtains or guesses the token, they gain full admin access to the session.
UUIDs are cryptographically random (122 bits of entropy). Optional password gate adds a layer.
Implement server-side admin authentication. Add rate limiting on session lookups.
Anonymous auth allows anyone to create identities. A malicious user could create multiple identities to vote multiple times.
Unique constraint on (question_id, participant_id) prevents duplicate votes per identity.
Implement device fingerprinting or IP-based rate limiting for vote submission.
Participants can modify their votes directly via API calls, bypassing the UI.
RLS ensures participants can only modify their own votes. locked_in field could be used to prevent changes.
Malicious JSON import files could contain XSS payloads or oversized data.
Zod schema validation, 5MB file size limit, data sanitization on render.
Session templates store full session blueprints as JSONB in the database. A malicious user could craft API calls to insert oversized or malformed blueprints.
Templates are serialized through the session-template-api which validates structure before storage. Blueprint data is re-validated on load. RLS policies restrict template creation to authenticated users.
Participants select their team client-side during lobby. A malicious user could modify the team_id sent with their vote via direct API calls, potentially skewing team-based results.
Team assignment is a convenience feature for result filtering, not a security boundary. Votes are still uniquely constrained per participant. Team names are validated against the session's configured team list. The impact is limited to one participant appearing in the wrong team's results.
Anonymous voting by design means votes cannot be attributed to real identities.
This is an intentional feature. Votes are tied to anonymous auth.uid() for uniqueness enforcement only.
All authenticated users can SELECT from all tables. Session IDs are guessable nanoid strings. Vote reasons are exported in plain text.
Session IDs use nanoid (21 characters). Export is admin-only action.
Restrict SELECT queries to session participants only. Add session-level access control.
Supabase anon key is publicly visible in the client bundle.
This is expected for Supabase public clients. Security relies on RLS, not key secrecy.
Uploading large JSON files (up to 5MB) could slow down the client or cause memory issues.
5MB file size limit. Client-side parsing with error handling.
Creating many WebSocket connections could exhaust Supabase realtime capacity.
Supabase has built-in connection limits. Presence tracking helps monitor participants.
Authenticated users could upload many or large image files to the Supabase Storage bucket, consuming storage quota.
Client-side image compression (browser-image-compression) reduces file sizes before upload. RLS policies restrict uploads to authenticated users with path-based access. Supabase plan storage quotas provide an upper bound.
If a participant obtains the admin_token URL, they gain full admin privileges for that session.
Admin token is a separate UUID. RLS checks created_by for mutation operations.
Implement proper admin authentication. Use HTTP-only cookies instead of URL tokens.
The optional password gate is client-side only. It can be bypassed by clearing sessionStorage or using dev tools.
This is a convenience feature, not a security control. True authorization is enforced by RLS.
| Threat ID | Threat | Likelihood | Impact | Risk Level | Status |
|---|---|---|---|---|---|
| S1 | Admin Token Guessing | Low | High | HIGH | Mitigated |
| S2 | Multiple Vote Identities | Medium | Medium | MEDIUM | Mitigated |
| T1 | Direct API Vote Manipulation | Medium | Low | MEDIUM | Accepted |
| T2 | Malicious Import Files | Low | Medium | MEDIUM | Mitigated |
| I1 | Session Data Exposure | Medium | Medium | HIGH | Partial |
| I2 | API Key Exposure | High | Low | MEDIUM | By Design |
| D1 | Large Import DoS | Low | Low | LOW | Mitigated |
| D2 | WebSocket Flooding | Low | Medium | MEDIUM | Mitigated |
| E1 | Participant to Admin | Low | High | HIGH | Partial |
| E2 | Password Gate Bypass | High | Low | MEDIUM | By Design |
| T3 | Template Blueprint Tampering | Low | Low | LOW | Mitigated |
| D3 | Storage Bucket Abuse | Low | Low | LOW | Mitigated |
| T4 | Team Assignment Manipulation | Low | Low | LOW | Accepted |
| Control | Type | Description | Effectiveness |
|---|---|---|---|
| Row Level Security (RLS) | Authorization | Database-level access control based on auth.uid() | Strong |
| Anonymous JWT Auth | Authentication | Unique identity per browser session | Moderate |
| HTTPS/TLS | Transport | Encryption in transit via Vercel & Supabase | Strong |
| Zod Validation | Input Validation | Schema validation for imported data | Strong |
| UUID Admin Tokens | Access Control | Cryptographically random session identifiers | Moderate |
| Client Password Gate | Access Control | Optional password for admin routes | Weak |
| Unique Vote Constraint | Data Integrity | One vote per participant per question | Strong |
| Data Element | Classification | Storage | Notes |
|---|---|---|---|
| Session ID | Internal | PostgreSQL | Shared via QR code/URL |
| Admin Token | Confidential | PostgreSQL, URL | Grants full session control |
| Vote Values | Internal | PostgreSQL | Anonymous, aggregated for display |
| Vote Reasons | Confidential | PostgreSQL | May contain personal opinions |
| Participant ID | Public | PostgreSQL | Anonymous UUID, no PII |
| Question Text | Internal | PostgreSQL | Admin-created content |
| Team Names | Internal | PostgreSQL (JSONB) | Admin-configured, up to 5 per session |
| Team Assignment (vote) | Public | PostgreSQL | Participant's self-selected team |