Security & Authentication
Authentication, authorization, and security infrastructure for the AIQLick platform.
For the code-reviewed role matrix, company membership rules, sharing permissions, and AI billing gates, see Roles & Permissions.
JWT Authentication
All authenticated GraphQL operations pass through JwtAuthGuard:
- Extract token from
Authorization: Bearer <token>header - Verify signature against
JWT_SECRET - Decode payload (
sub: userId,email) - Load full user from DB (includes
selectedCompanyId,isSuperAdmin, company roles) - Attach merged payload + user to
req.user
Token Structure
| Field | Type | Description |
|---|---|---|
sub | string (UUID) | User ID |
email | string | User email |
iat | number | Issued-at timestamp |
exp | number | Expiration timestamp |
Google OAuth
Google OAuth uses passport-google-oauth20 with email and profile scopes:
GET /auth/google --> Google Consent Screen --> GET /auth/google/callback
--> GoogleStrategy.validate() extracts profile
--> authService.validateGoogleUser() creates or updates user
--> authService.generateJwtToken() signs JWT
--> Redirect to FRONTEND_URL/auth/callback?token=<jwt>&user=<json>
Guard Hierarchy
Guards execute left-to-right in the @UseGuards() array. The standard chain is:
| Order | Guard | Decorator | Purpose |
|---|---|---|---|
| 1 | JwtAuthGuard | -- | Verify JWT, load user, attach to req.user |
| 2 | RoleGuard | @HasRole(...) | Check user has required role for company (OR logic) |
| 2 | PermissionGuard | @HasPermission(...) | Check user has required permission (OR logic) |
| 2 | SuperAdminGuard | @RequireSuperAdmin() | Check user.isSuperAdmin === true |
| 2 | CompanyWriteGuard | -- | Block viewer-tier roles from any state-changing mutation on a company-scoped resource (see CompanyWriteGuard) |
| 3 | PlanLimitGuard | @CheckPlanLimit(...) | Check company plan allows this action |
Guards at the same order number are alternatives -- use one per resolver method, not all at once.
Common Guard Patterns
// Auth only
@UseGuards(JwtAuthGuard)
// Auth + role check (class-level JwtAuthGuard assumed)
@UseGuards(RoleGuard)
@HasRole('Company Admin', 'Super Admin')
// Auth + role + plan limit
@UseGuards(RoleGuard, PlanLimitGuard)
@HasRole('Company Admin', 'Super Admin', 'Internal Sales')
@CheckPlanLimit('maxUsersPerCompany')
// Super admin only
@UseGuards(JwtAuthGuard, SuperAdminGuard)
@RequireSuperAdmin()
RBAC Summary
The RBAC model is documented in detail on Roles & Permissions. Keep this page focused on authentication and security boundaries; do not duplicate the role matrix here.
Current model:
- company membership lives in
UserCompanyRole - global support assignments live in
UserRole - platform super-admin access is
User.isSuperAdmin - production and development RDS have 8 roles, 39 permissions, and matching
_RolePermissionsjoins - runtime authorization mostly uses role-name checks, tier helpers, resource-specific services, share permissions, and billing gates
@HasPermission(...)andPermissionGuardexist, but application resolvers do not currently use them
Plan Limits
Plan limits restrict resource creation based on subscription tier. Values: -1 (unlimited), 0 (disabled), >0 (specific limit).
| Type | Fields | Reset |
|---|---|---|
| Absolute | maxJobs, maxTalentPools, maxContacts, maxPipelines, maxCvCollections, maxSharedTalentLists, maxCollaborationInvites, maxUsersPerCompany | Never |
| Monthly | maxInterviewsPerMonth, maxMeetingsPerMonth, maxConnectionRequestsPerMonth, maxNewsPerMonth, maxCvParsesPerMonth, maxAgentMessagesPerMonth | 1st of month UTC |
When exceeded, the guard returns a PLAN_LIMIT_EXCEEDED error (HTTP 403) with current usage and limit details.
AI-related plan limits are prechecked by the backend AI gateway for operations that declare planLimitKey, and are also enforced by the background-tasks BillingGate during execution. See Roles & Permissions and AI Gateway.
Resource Access Gates
Beyond the JWT/RBAC layer, individual resources sometimes need their own runtime authorization gate based on domain state rather than just identity. The canonical example is the interview Jitsi room: once a candidate withdraws or is rejected, the room must become inaccessible to both the candidate and the recruiter — even if the candidate is still listed as a MeetingAttendee.
Pattern: Single Source of Truth + Narrow Boundary
- One module owns the domain rule.
src/talent-management/candidate/candidate-status.constants.tsis the single source of truth for "active vs inactive candidate" — see Candidate Management → Active vs Inactive Candidates. Every guard, filter, and cascade imports from this module instead of inlining=== WITHDRAWN || === REJECTEDchecks. - Gate at the actual security boundary, not earlier. For interviews, the boundary is the function that issues a Jitsi join link (
MeetingService.getAttendeeLink), not the metadata read (getMeeting). Gating metadata reads would block recruiters from reviewing notes/transcripts of cancelled interviews — a legitimate use case. - Belt-and-suspenders list filters. Every "list meetings" query also excludes inactive-candidate rows so stale UI rows disappear even if the cascade listener has not yet run. Both layers exist independently so a single point of failure cannot expose the room.
Worked Example: Interview Jitsi Room
This gate fires for both candidate and recruiter requests — neither side should be joining a cancelled interview's room. Recruiters who need to review what was discussed can still call getMeeting (metadata) and transcriptText directly.
The database stores only a generic room URL (e.g. https://book.aiqlick.com/aiqlick-interview-abc-xyz-123) — no JWT is embedded. User-specific JWTs are minted per-participant at the moment they call getMyMeetingLink, not at booking time. This prevents the old vulnerability (SUP-00027) where sharing a stored meetingUrl would expose the interviewer's name and email to the recipient via the embedded JWT. Email confirmations also generate per-attendee JWTs so each recipient sees their own identity in Jitsi.
When to Add a New Resource Gate
Use this pattern when:
- The access decision depends on domain state that can change after the resource is created.
- A simple JWT/RBAC check is too coarse — the user might still be a "valid attendee" in the literal sense but should no longer have access.
- Gating at a list/feed level isn't enough because the row might still be served from a stale cache.
Do NOT use this pattern when a @CheckPlanLimit decorator, @RequireSuperAdmin, or company-scoped @HasRole already covers the case.
External Sharing Tokens
External sharing provides unauthenticated access to specific resources via secure, time-limited tokens.
- Generation: Two
crypto.randomUUID()values concatenated (~68 chars, 256 bits of entropy) - Default expiry: 168 hours (7 days)
- Validation:
isActive === trueANDexpiresAtis in the future - Access tracking: Each access increments
accessCount(non-blocking, errors swallowed) - Cleanup:
cleanupExpiredShares()deactivates expired tokens across all share tables
Shareable entities: TalentPool, UserProfile, Talent, SharedTalentList, Pipeline, CvCollection.
Stripe Webhook Security
The webhook endpoint (POST /stripe/webhook) implements three layers:
Layer 1 -- Signature verification: Raw request body verified against STRIPE_WEBHOOK_SECRET using stripe.webhooks.constructEvent(). Requires rawBody: true in NestJS bootstrap.
Layer 2 -- Idempotency: StripeEvent table with stripeEventId @unique. Duplicate events return HTTP 200 immediately. Concurrent duplicates handled via Prisma P2002 catch.
Layer 3 -- Error strategy: Critical events (invoice.paid, invoice.payment_failed) rethrow errors for Stripe retry. Non-critical events (trial_will_end, invoice.upcoming) swallow errors and return 200.
Sensitive Field Redaction
The LoggingInterceptor automatically redacts these fields from logged request variables (case-insensitive, recursive):
password, token, secret, apiKey, accessToken, refreshToken, authorization, creditCard, cvv, ssn
Distributed Tracing
CorrelationIdMiddleware injects a correlation ID into every request:
- Read
x-correlation-idorx-request-idheader - Fall back to generated UUID v4
- Attach to
req.correlationIdandx-correlation-idresponse header
This enables tracing a single request across backend, background-tasks, and other services.