Practical security guidance for a public family tree application. The goal is protecting real people's personal data — especially living people and minors — while keeping the app discoverable and useful. Updated: 2026-06-08.
What Arborkin is: a publicly accessible family tree app. Anyone can register and search public trees. Family data is gated behind membership; public visitors get a limited read-only view.
What we're protecting against:
- Unauthenticated access to private family data (membership required for full access)
- Living minors' data appearing in public search results or being indexed
- A registered user accessing another family's data
- A former member retaining access after removal
- Unauthorized users claiming a person node without approval
- Account takeover (stolen credentials, session hijack)
- Standard web vulnerabilities: XSS, injection, CSRF
What we are NOT protecting against:
- Nation-state actors
- Compliance regimes (HIPAA, SOC 2, PCI) — none apply here
- Zero-day exploits in the .NET or Azure runtime
| Data | Sensitivity | Notes |
|---|---|---|
| Living minors: any data | High | Hidden from all public views; limited even to Members |
| Living adults: name, birth year, photo | Medium | PII; public view shows name only |
| Deceased people: full dates, places | Low | Historical; public genealogy norm |
| Biography notes | Medium–High | May contain health, immigration, relationship details |
| User email addresses | Medium | Standard PII; never exposed in public views |
| Audit log | Low–Medium | Admin-only |
| Tier | Who | Deceased | Living adult | Minor |
|---|---|---|---|---|
| Public | Unauthenticated / Viewer | Full record | Name only | Hidden ("Private") |
| Member | Authenticated family member | Full record | Full record | First name + position only |
| Admin / SuperUser | Admin or SuperUser | Full record | Full record | Full record |
Minor detection: Person.DeathDate == null && BirthDate >= today − 18 years.
Person.IsMinorOverride can force or override this when BirthDate is unknown.
- Cookie auth, not JWT — Blazor Server runs on a persistent SignalR connection
- Google OAuth primary — least friction; no password to forget
- Email/password fallback — ASP.NET Core Identity's built-in bcrypt/PBKDF2 hashing
- Open registration — anyone can create an account; family membership is separate
- Registered, no family — can log in, sees onboarding page only
- Pending claim — has a person node claimed (
PersonClaimStatus = Pending); awaiting admin approval - Member — claim approved (
PersonClaimStatus = Approved);UserFamilyrow exists; full family access - Admin —
UserFamily.Role = "Admin"; can manage members, restore records, view audit log - SuperUser —
AppUser.IsSuperUser = true; global access across all families
- Sessions expire on browser close by default; "remember me" is opt-in
- Logout invalidates the server-side session, not just the cookie
- Super-user and Admin roles require MFA (ASP.NET Identity TOTP)
- Minimum 10 characters — length over complexity
- Check against HaveIBeenPwned API on registration
- No forced periodic rotation
- Password reset:
UserManager.GeneratePasswordResetTokenAsync→ email link →ResetPasswordAsync - Google-only users have no password; show "sign in with Google" on the reset page
- New accounts:
EmailConfirmed = false; confirmed via link in registration email - Unconfirmed accounts can log in but cannot access family data
SuperUser IsSuperUser = true on AppUser; global; all permissions; can delete any user
Admin UserFamily.Role = "Admin"; scoped per family; manages members, approvals, restore
Member UserFamily.Role = "Member"; full CRUD on people and relationships in their family
Viewer Unauthenticated public access to public families; read-only, visibility-tiered
- Nav link hidden —
<AuthorizeView Roles="Admin,SuperUser">prevents casual discovery - Page-level attribute —
@attribute [Authorize(Roles="Admin,SuperUser")]redirects on direct URL access - Service-level visibility tier — enforced in mapper/service, not just UI; public vs member vs admin data shape
- Family scoping — all queries filtered by
FamilyIdfrom current user'sUserFamilyrows - Claim approval gate —
PersonClaimStatus = Pendingblocks family access until admin approves
- Members cannot see audit logs, deleted records, or pending claims — Admin-only
- Admins cannot promote other users to Admin — only SuperUser can
Family.IsPubliccan only be set by a family Admin or SuperUserFamily.RequireApprovaldefaults totrue; Admin can disable it per family
- Minors never appear in public search results or unauthenticated views
- The "find my node" wizard hides minors at the query level — not just the UI
- Even Members see only first name and family position for minors; no dates or places
- Admins and SuperUsers see full data (needed for record management)
Person.IsMinorOverrideallows manual override when BirthDate is unknown:null= derive from BirthDate (default)true= force treat as minor regardless of datesfalse= force treat as adult (e.g., adopted adult with unknown birthdate)
- Blazor Server runs entirely server-side — no sensitive data in the browser bundle
- All user interaction goes through SignalR; there is no REST API surface to enumerate
[JSInvokable]methods are the only JS→C# bridge; keep them minimal and validate inputs- Antiforgery is enabled (
app.UseAntiforgery()) — do not disable it
- All service methods validate DTOs before touching the database
PersonServicehas 70+ business rules (date bounds, age gaps, spouse conflicts)- Never trust client-supplied IDs for authorization — always re-check ownership server-side
- EF Core parameterizes all queries — no raw SQL strings with user input
- If raw SQL is ever needed, use
FromSqlRawwith@p0-style parameters only
- Blazor renders all string values HTML-encoded by default —
@variableis safe @((MarkupString)html)bypasses encoding — never use it with user-supplied content
- Connection strings and API keys: User Secrets in development
- Production: Azure Key Vault or App Service environment variables — never commit secrets
- App Service: force HTTPS; set
ASPNETCORE_ENVIRONMENT=Production - Azure SQL: connection string in App Service Configuration; Azure Defender enabled; 7-day backup retention; app user has
db_datareader + db_datawriter + EXECUTEonly - Blob Storage: private container; SAS tokens with short expiry for photo URLs; blob soft delete enabled (7 days)
- TLS: managed certificate; TLS 1.2 minimum
- Person, Relationship, Medium: soft-deleted (
DeletedAtset); Admin can restore within retention window - Hard delete not exposed in UI — deliberate
- 90-day hard-purge policy (not yet implemented): background job removes soft-deleted rows older than 90 days
- Audit log rows are never deleted
| Not doing | Why it's OK |
|---|---|
| Formal pen-test | Small user base, not a financial/medical SaaS |
| WAF / DDoS protection | Azure App Service basic rate limiting is sufficient |
| Encrypted columns | Azure SQL TDE covers at-rest encryption |
| IP allowlisting | Users travel; fixed IPs don't work |
| Per-request read audit logging | Log writes and deletes only; read logging is noise |
| GDPR Data Protection Officer | Not required for this scale |
Status key: OPEN = unmitigated gap, PARTIAL = some protection exists, OK = well covered.
PersonService.GetByIdAsync()has noFamilyIdcheck — any authenticated user who knows a GUID can fetch any person across family boundaries. Same gap exists inRelationshipServiceandMediumService.DevAuthHandler.csbypasses all authentication whenDevAuth:Enabled = true. If this reaches a deployed environment the app is fully open.- Fix: add family-scope guard to every single-entity lookup; add a startup assertion that
DevAuth:Enabledis false in non-Development environments.
"AllowedHosts": "*"inappsettings.jsonallows any Host header — enables Host Header Injection. Lock to the actual domain in production.- No Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, or Referrer-Policy response headers configured anywhere.
appsettings.Development.jsoncontains a hardcoded super-user email andDevAuth:Enabled = true— must never reach a deployed environment.
Azure.Storage.Blobs 12.29.0-beta.1is a pre-release package used in production (FamilyTree.Core.csproj). Upgrade to a stable release.- No committed NuGet lock file — enables dependency-confusion attacks. Add
RestorePackagesWithLockFile = trueto project files.
- Password policy is weakened:
RequireDigit = false,RequireNonAlphanumeric = false,RequireUppercase = false(Program.cs~L74). "aaaaaaaaaa" is a valid password. - Note:
security.mdlines 74 and 78 claim MFA is required for Admin/SuperUser and that HaveIBeenPwned is checked on registration — neither was found in the codebase. These should be treated as aspirational until implemented.
- No raw SQL anywhere; EF Core parameterizes all queries.
Faq.razoruses@((MarkupString)item.A)to render FAQ answers as raw HTML. If FAQ content ever comes from the database or user input without sanitization, this is a stored XSS vector. (security.mdline 146 correctly documents the rule — enforce it here.)
- Password reset tokens are in the URL query string (
AuthService.cs~L204) — tokens leak viaRefererheaders, server logs, and browser history. Same issue with registration invite tokens. - No rate limit on invite generation — an admin could spam invitations with no throttle.
- No "a password reset was requested for your account" notification email to the account owner.
- No MFA/2FA implemented (
security.mdline 74 says it's required — it is not yet built). - Password reset tokens are not session- or IP-bound; token theft before redemption is undetected.
- No login history visible to users; no new-device alerts.
AuditLogServicesilently swallows its own exceptions (~L48) — audit failures are invisible to admins.- Audit log rows are mutable/deletable by anyone with direct DB access; not tamper-proof.
- No real-time alerts for suspicious events (mass deletes, repeated lockouts, role escalation, new registrations). Admins must manually poll the admin page.
- Rate-limit rejections are not logged (
Program.cs~L65). - No integration with Application Insights, Sentry, or equivalent for centralized error visibility.
- Generic error messages used throughout;
UseExceptionHandleractive in production. - Some error messages include entity GUIDs (minor information leakage).
- If
db.Database.MigrateAsync()throws on startup the app crashes with no graceful degradation.
- Unauthorized access → revoke
UserFamilyrow; check audit log; rotate secrets if needed - Data accidentally deleted → restore from soft-delete in
/admin → Deleted; if hard-deleted, restore from Azure SQL backup - Credential compromise → invalidate security stamp in Identity (forces re-login for all sessions); check
AuditLog - Minor data exposed publicly → check
Person.IsMinorOverrideandBirthDate; auditPersonServicevisibility tier logic - Blob storage exposed → regenerate storage SAS key; audit Azure blob access logs
- Secrets committed to git → rotate immediately; use
git filter-repoto remove from history; assume compromised