Authentication Security Features
Security hardening for authentication across DigiWedge services.
Summary
| Feature | Status | Notes |
|---|---|---|
| Progressive lockout | Complete | 15m → 30m → 60m durations |
| Per-failure throttling | Complete | +2s, +4s, +8s delays |
| Risk-based signals | Complete | IP/device/geo tracking |
| CSRF protection | Complete | Double-submit cookies |
| Cookie-only tokens | Complete | Phase 5 complete; web + mobile |
| Structured error codes | Complete | Refresh token failures |
| Refresh rate limiting | Complete | Per-IP + per-session limits |
| Account deletion | Complete | GDPR self-service (#10496) |
Lockout Policy
- Progressive backoff windows after repeated failures.
- Per-failure throttling to slow brute-force attempts.
- Risk signals captured for IP, device, and geo anomalies.
CSRF Protection
- Double-submit cookie pattern with
XSRF-TOKEN. - SameSite cookies enforced for auth endpoints.
Cookie-Only Sessions
See Cookie-Only Sessions for infrastructure and implementation details.
Refresh Token Error Codes
Structured error codes for refresh token failures (#10450). The server returns specific codes in the error response for client-side handling.
| Code | Scenario |
|---|---|
REFRESH_SESSION_NOT_FOUND | Session record not in database |
REFRESH_TOKEN_INVALID | Token doesn't match stored hash |
REFRESH_SESSION_REVOKED | Session explicitly revoked |
REFRESH_SESSION_DELETED | Session has been deleted |
REFRESH_SESSION_EXPIRED | Session past expiry date |
REFRESH_USER_NOT_FOUND | User account not found |
REFRESH_USER_INACTIVE | User account inactive/disabled |
Client Usage
Frontend clients can extract the error code from the response:
import { RefreshErrorCode } from '@digiwedge/auth';
// In error handler:
const code = error.response?.data?.code;
if (code === RefreshErrorCode.SESSION_EXPIRED) {
// Handle expired session - redirect to login
}
The hooks-auth-web library's classifyAxiosError() function automatically extracts server-provided codes for consistent error handling.
Account Deletion (GDPR Article 17)
Self-service account deletion with OTP confirmation (#10496). Provides GDPR-compliant "right to erasure" and data portability.
Status: Complete (Backend + SDK + UI). All layers implemented including TeeTime Web, Mobile, and Admin UI.
Endpoints
| Endpoint | Method | Auth | Rate Limit | Description |
|---|---|---|---|---|
/auth/account/delete-request | POST | JWT | 3/5min | Request deletion (sends OTP via email/WhatsApp) |
/auth/account | DELETE | JWT | 5/5min | Confirm deletion with OTP code |
/auth/account/restore-request | POST | None | 3/5min (per-IP) | Request restore OTP (self-service) |
/auth/account/restore | POST | None | 5/5min (per-IP) | Confirm restore with OTP code |
/auth/account/export | GET | JWT | 5/hour | Export personal data (JSON or CSV) |
Deletion Flow
- User requests deletion via
POST /auth/account/delete-request - OTP sent to verified email and WhatsApp (if verified)
- User confirms with OTP via
DELETE /auth/account - Account soft-deleted (
deletedAtset), all sessions revoked - 30-day grace period for restoration (configurable via
ACCOUNT_DELETION_GRACE_DAYS) - After grace period, permanent deletion via scheduled task
Self-Service Restore Flow
Users can restore their deleted account without authentication:
- User requests restore via
POST /auth/account/restore-requestwith email - OTP sent to deleted account's email (enumeration-resistant)
- User confirms with OTP via
POST /auth/account/restore - Account restored if within grace period
Export Data Format
The export endpoint supports both JSON (default) and CSV formats for GDPR Article 20 data portability compliance.
Query parameter: ?format=json (default) or ?format=csv
JSON Format
{
"exportedAt": "2026-01-04T00:00:00.000Z",
"user": { "id", "email", "createdAt", "status", "phoneVerified", "emailVerified" },
"profiles": [{ "provider", "givenName", "familyName", "createdAt" }],
"roles": [{ "id", "name", "description" }],
"tenants": [{ "id", "name", "slug" }],
"sessions": [{ "id", "createdAt", "device", "ipAddress" }],
"mfa": [{ "method", "verified", "createdAt" }],
"auditLog": [{ "action", "createdAt", "ipAddress" }]
}
CSV Format
Returns RFC 4180 compliant CSV with separate sheets for each data category:
{
"exportedAt": "2026-01-06T...",
"sheets": {
"user": "id,email,createdAt,status,...\nu-123,user@example.com,...",
"profiles": "provider,givenName,familyName,...",
"roles": "id,name,description",
"tenants": "id,name,slug",
"sessions": "id,createdAt,expiresAt,...",
"mfa": "method,verified,createdAt",
"auditLog": "action,createdAt,ipAddress,status"
}
}
Passwords, tokens, and other sensitive data are excluded from exports.
Security Features
- Brute-force protection: OTP attempts are tracked and locked after 5 failed attempts
- Per-IP rate limiting: Restore endpoints use explicit IP-based rate limiting (handles X-Forwarded-For)
- Enumeration-resistant: All request endpoints return 204 regardless of user existence
- Case-insensitive email: Restore flow uses case-insensitive email lookup for better UX
- Audit logging: All deletion/restore events logged with IP address and user agent
- Session revocation: All active sessions terminated on successful deletion
- Token invalidation: All password reset tokens, MFA tokens, and OTPs invalidated on delete/restore
- Pre-deletion validation: Downstream services can block deletion via event hooks (e.g., active bookings)
Notification Templates
Account lifecycle notifications use dedicated template types:
| Template | Purpose | Channels |
|---|---|---|
account_deletion_requested | OTP code for deletion confirmation | EMAIL, SMS, WHATSAPP |
account_deleted | Confirmation with grace period info | EMAIL, SMS, WHATSAPP |
account_restore_requested | OTP code for restore confirmation | EMAIL, SMS, WHATSAPP |
account_restored | Restoration success notification | EMAIL, SMS, WHATSAPP |
Templates are validated at IDP startup via TemplateHealthCheckService. Missing critical templates (EMAIL channel) will log errors.
Seed templates: See docs/internal/runbooks/seed-auth-templates.md
Configuration
| Environment Variable | Default | Description |
|---|---|---|
ACCOUNT_DELETION_GRACE_DAYS | 30 | Days before permanent deletion |
ACCOUNT_DELETION_OTP_TTL_MIN | 15 | OTP code expiry in minutes |
DEFAULT_COMMS_TENANT_ID | global | Fallback tenant for notifications |
ENABLE_RETENTION_CRON | 0 | Enable scheduled permanent deletion (1 to enable) |
ACCOUNT_RETENTION_DAYS | 30 | Days after soft-delete before permanent deletion |
Retention Cron Job
When enabled (ENABLE_RETENTION_CRON=1), a scheduled task runs daily at 2 AM to permanently delete users whose deletedAt timestamp exceeds the retention period:
- Finds eligible users: Queries users where
deletedAt + ACCOUNT_RETENTION_DAYS < now - Permanently deletes each user: Removes all related records (profiles, roles, tenants) in a transaction
- Emits events: Publishes
USER_PERMANENTLY_DELETEDevent for each deletion - Logs operations: Audit trail for GDPR compliance
Identity Events
Account lifecycle events are emitted for downstream service consumption (e.g., TeeTime Player, SCL Member cleanup):
| Event | Trigger | Payload |
|---|---|---|
identity.user.pre_deletion_validate | Before soft-delete | userId, email, addBlockingReason() callback |
identity.user.deleted | Account soft-deleted | userId, email, gracePeriodDays, permanentDeletionAt |
identity.user.restored | Account restored | userId, email, originalDeletionAt |
identity.user.permanently_deleted | Retention cron deletes | userId, email, originalDeletionAt, daysInDeletedState |
Pre-deletion validation (blocking deletion if conditions not met):
import { OnEvent } from '@nestjs/event-emitter';
import { IdentityEvents, PreDeletionValidatePayload } from '@digiwedge/auth';
@OnEvent(IdentityEvents.PRE_DELETION_VALIDATE)
async validateDeletion(payload: PreDeletionValidatePayload) {
const activeBookings = await this.bookingRepo.findActiveByUser(payload.userId);
if (activeBookings.length > 0) {
payload.addBlockingReason('TeeTime', 'User has active bookings');
}
}
Listening to lifecycle events (in downstream services):
import { OnEvent } from '@nestjs/event-emitter';
import { IdentityEvents, UserDeletedPayload } from '@digiwedge/auth';
@OnEvent(IdentityEvents.USER_DELETED)
async handleUserDeleted(payload: UserDeletedPayload) {
// Clean up related domain records (Player, Member, etc.)
}