CommunityPay handles sensitive personal and financial data — Social Security numbers, bank account details, dates of birth, and payment credentials. This page describes how that data is protected at rest, in transit, and in error reporting pathways.
Field-Level Encryption
Sensitive fields are encrypted at the model layer using Fernet symmetric encryption before being written to the database. Fernet is symmetric, authenticated encryption.
Encrypted Fields
| Model | Field | Data Type |
|---|---|---|
| HOA | controller_ssn | Social Security Number of the association's controller |
| HOABeneficialOwner | ssn | Social Security Number |
| Profile | ssn | Social Security Number |
| Vendor | bank_routing_number, bank_account_number | A vendor's bank routing and account numbers |
| HOAClaimFormSubmission | payout_routing_number, payout_account_number | Bank routing and account numbers submitted on a claim form |
The controller's date of birth is stored as a date and is not encrypted. The association's own bank is identified by a provider token and its last four digits, not by account numbers held in the database. The table lists the encrypted fields in the live data model. The bank-file records of a payment rail that is not live also encrypt their receiver account and routing numbers.
Encryption happens transparently through Django encrypted model fields. Application code reads and writes plaintext values; the encryption and decryption occur at the ORM boundary. The encryption key is loaded from environment variables and is never stored in the codebase.
What This Means in Practice
- Database dumps contain ciphertext, not plaintext SSNs or vendor bank numbers
- Database administrators with read access see encrypted values
- Backup restoration preserves encryption (the key is separate from the data)
- Log files that capture model field values capture ciphertext
The encryption is symmetric (Fernet), which means the same key encrypts and decrypts. Key rotation requires re-encrypting existing records, which is a controlled migration operation.
Error Reporting Hygiene
CommunityPay uses Sentry for error tracking and performance monitoring. Error reports can inadvertently contain sensitive data from request payloads, headers, or application context. The platform applies multiple layers of filtering before any data reaches Sentry.
Layer 1: Default PII Exclusion
The Sentry SDK is configured with send_default_pii=False. This means Sentry does not automatically capture:
- User IP addresses
- User cookies
The application attaches the signed-in user's numeric id to each event on purpose, so an error can be matched to an account; the user's email and username are masked as described below. A request body, when an event carries one, passes through the layers below before the event leaves the application.
Layer 2: before_send Hook
Every Sentry event passes through a before_send function before leaving the application. This function applies three categories of filtering:
Request data filtering: 21 sensitive field names are compared with the keys of the request body. A key equal to one of these names is replaced; a key that only contains one, such as bank_account_number or password1, is not matched:
- password, secret, token, api_key, access_key, private_key
- ssn, social_security, ein
- bank_account, routing_number, account_number
- stripe_customer, dwolla_customer, plaid_access_token
- card_number, cvv, pin
- date_of_birth, dob, birth_date
A matching key's value is replaced with [FILTERED] before the event is transmitted. The hook does not rewrite values held in stack frames.
Header filtering: Authorization, cookie, API key, and webhook signature headers are replaced with [FILTERED]. This prevents authentication tokens and webhook verification secrets from appearing in error reports.
Email masking: User email addresses are masked to ***@domain. The domain is preserved because it aids debugging (distinguishing internal vs. external users, identifying which organization a user belongs to). The local part is stripped because it often contains personally identifiable information. A username that is itself an email address is masked the same way; the numeric user id is kept.
Extra context filtering: Any key in the Sentry event's extra context that contains one of the 21 sensitive words, in any letter case, is replaced with [FILTERED]. This catches cases where application code adds debug context that inadvertently includes sensitive data.
Layer 3: Transaction Filtering
The before_send_transaction function excludes routine transactions from Sentry:
- Health check endpoints (
/health/,/ping/,/robots.txt) - Static file requests (
/static/...)
These requests generate high volume with low diagnostic value. Excluding them reduces Sentry event consumption and keeps the error stream focused on application issues.
What This Architecture Prevents
- A request body key named exactly like a sensitive field, such as
passwordorssn, is replaced before the report leaves the application - A Stripe webhook processing error will not leak the webhook signature from the request headers
- A context key on an error report that is named like a sensitive field is replaced before the report leaves the application
Session Security
Session configuration enforces strict boundaries on session lifetime and cookie behavior:
| Setting | Value | Purpose |
|---|---|---|
| SESSION_COOKIE_AGE | 3,600 seconds (1 hour) | Maximum session duration |
| SESSION_EXPIRE_AT_BROWSER_CLOSE | True | Sessions end when browser closes |
| SESSION_COOKIE_HTTPONLY | True | JavaScript cannot access session cookie |
| SESSION_COOKIE_SECURE | True (production) | Cookie only transmitted over HTTPS |
| CSRF_COOKIE_HTTPONLY | True | JavaScript cannot access CSRF cookie |
| CSRF_COOKIE_SECURE | True (production) | CSRF cookie only over HTTPS |
| SESSION_COOKIE_SAMESITE | Lax (production) | Cookies are not sent on cross-site subrequests. Lax still allows top-level navigations, which OAuth sign-in requires |
Why 60-Minute Sessions
HOA financial operations are high-value targets. A forgotten session on a shared computer is a vector for unauthorized access. One hour balances usability (a board member reviewing financial reports does not need to re-authenticate every 15 minutes) with security (an abandoned session expires before the next business day).
Response Header Hygiene
The application strips information-leaking headers from its HTTP responses:
| Action | Header | Purpose |
|---|---|---|
| Remove | Server | Hides web server software and version |
| Remove | X-Powered-By | Hides application framework |
| Add | X-Content-Type-Options: nosniff | Prevents MIME type sniffing |
| Add | X-XSS-Protection: 1; mode=block | Legacy XSS protection (defense in depth) |
| Add | Referrer-Policy: strict-origin-when-cross-origin | Limits referrer information leakage |
| Add | Expect-CT: max-age=86400, enforce | Certificate Transparency enforcement. Current browsers ignore it |
The application removes the Server and X-Powered-By headers from its responses because they reveal implementation details useful for targeted attacks. Knowing that an application runs on a specific web server version or framework narrows the attacker's search space for known vulnerabilities. The edge network in front of the application adds its own Server header.
Brute-Force Protection
Authentication endpoints are protected by django-axes:
| Setting | Value |
|---|---|
| Failure limit | 5 attempts |
| Cooloff time | 30 minutes |
| Lockout method | IP-based |
After 5 failed login attempts from the same IP, the IP is locked out for 30 minutes. This applies uniformly. There is no escalating delay and no CAPTCHA fallback.
All authentication events — successful logins, failed attempts, and lockouts — are logged to the audit trail.
Content Security Policy
CSP enforcement varies by route type:
Application Routes (Strict)
Script sources are restricted to:
- The application's own domain (
'self') - A short list of CDNs that serve UI framework libraries
- Stripe.js (
js.stripe.com) - The browser SDK for error reporting
- A per-request cryptographic nonce for inline scripts
Frames are restricted to the application and Stripe's domains. Inline scripts without a valid nonce are blocked by the browser. The nonce is generated per request using 16 cryptographically random bytes, base64-encoded. It is available in templates as request.csp_nonce.
Public Pages Served From the Cache (Hashes)
A public page served from the shared cache cannot carry a per-request nonce, because the cache stores the whole response and would replay one nonce to every visitor. A nonce that is public and constant is not a nonce. These pages carry a SHA-256 hash of each inline script instead, computed from the exact body being sent, so a hash stays valid for exactly as long as the cached body does.
Admin and Dashboard Routes (Relaxed)
Admin and Plotly Dash routes require unsafe-inline for script-src because they inject inline scripts that cannot be nonce-tagged. These routes maintain CSP for other directives but relax the inline script restriction.
Style Sources
unsafe-inline is permitted for style-src across all routes. This is a known relaxation required by framework dependencies.
Two-Factor Sign-In
A signed-in account can enroll an authenticator app and recovery codes. A text-message code never confirms an action that moves money. A fresh code, confirmed within the last ten minutes, is asked for before paying a vendor by transfer, changing a vendor's bank, or changing who holds authority. A new authenticator cannot confirm a money action until the account's owner approves it from an email sent to the account's address. See Security & Trust for the full list of actions.
Permissions Policy
Browser capabilities are restricted via Permissions-Policy headers:
| Capability | Setting |
|---|---|
| Geolocation | Disabled entirely |
| Microphone | Disabled entirely |
| Camera | Disabled entirely |
| USB | Disabled entirely |
| Payment API | Self + Stripe only |
CommunityPay is a financial platform. It has no use for geolocation, microphone, camera, or USB access. Disabling these capabilities eliminates entire classes of potential abuse if the application were compromised.
The Payment API is restricted to the application's own origin and Stripe's domain. This allows Stripe Elements to function for payment processing while preventing any other origin from invoking the browser Payment API.
What We Don't Log
Audit logging captures the fact of an action, not the sensitive content. Specifically:
- Login events record the user, timestamp, IP, and success/failure — not the password
- Payment events record the amount, status, and payment method type — not the card number or bank account
- Webhook entries in the audit trail record the provider, event type, and processing result. The webhook event store keeps the complete payload and signature so a duplicate delivery can be refused and a failed one replayed
This is a deliberate design choice. The audit trail exists to prove what happened and who did it. It does not need to reproduce the sensitive content that was involved.
How CommunityPay Enforces This
- Fernet symmetric encryption on Social Security numbers and on vendor and claim-form bank routing and account numbers at the model layer
- Sentry before_send hook replaces 21 named sensitive keys in request data, matches 21 sensitive words in extra context, and replaces authentication and signature headers
- Email addresses masked to ***@domain in error reports — domain preserved for debugging, username stripped
- SENTRY_SEND_DEFAULT_PII set to False — no PII transmitted unless explicitly included and filtered
- Server and X-Powered-By headers removed from the application's responses to reduce fingerprinting surface
- Health check, ping, and static file transactions excluded from Sentry to reduce noise and cost