Security & Encryption
How sensitive data is protected at rest and how to keep it that way through deployment
Overview
Blazor Blueprint protects sensitive data in three layers:
- Field-level encryption for credentials and PII the app needs to read back later (API keys, OAuth secrets, names, phone numbers).
- One-way hashing for tokens you only need to verify (invitation links, recovery codes, single-use access links).
- Storage-layer protection — encrypted backups, network isolation, and disk encryption — handled at deploy time, not in code.
Encryption is on by default. There is a single config knob — Security:Encryption:Mode — but the only production-safe value is the default. The two dev-only modes refuse to start in any environment that is not Development, so an accidental flip fails loudly at startup rather than silently leaking plaintext into the database.
Field-Level Encryption
How it works
Tag a string property with the [Encrypted] attribute. The active backend picks it up automatically — a global BSON convention swaps the string serializer on MongoDB, and an EF value converter does the same job on PostgreSQL / SQL Server — running the value through ISecretProtector (ASP.NET Core Data Protection) on every read and write. Call sites only ever see plaintext; the database only ever stores ciphertext, on all three backends.
public class EmailSettings : IEntity
{
[Encrypted] public string ApiKey { get; set; } = string.Empty;
[Encrypted] public string SmtpPassword { get; set; } = string.Empty;
public string SenderEmail { get; set; } = string.Empty;
// ...
}
No code change at any call site. Settings services, repositories, and admin pages already deal in plaintext; the BSON layer takes care of the rest.
What's encrypted
EmailSettings.ApiKey— provider API key (SendGrid, MailerSend, Mailgun)EmailSettings.SmtpPassword— SMTP passwordExternalAuthSettings.FacebookAppSecret,GoogleClientSecret,MicrosoftClientSecret— OAuth provider secretsPlatformPushSettings.VapidPrivateKey— the deployment's Web Push private keySsoSettings.ClientSecret— per-organisation OIDC client secret (nested onOrganisation)ApiIntegration.ApiKey,ApiIntegration.Settings— third-party AI provider keys (OpenAI, OpenRouter, Ollama, N8N). Stored in the platform database; one set shared across every tenant. (SMS and inbound-email credentials are encrypted too, but they're notApiIntegration. The operator's default sender and inbound mailbox areSmsProviderCredentials/MailgunCredentialsrows in the platform organisation's own tenant database — not the platform database — used by every organisation that hasn't configured its own; an organisation that does keeps that override in its own tenant database. Payments below differ again — those are per-tenant only, with no shared row to fall back to.)PaymentProviderCredentials.ApiKey,PaymentProviderCredentials.Settings— Stripe / PayPal keys + webhook signing secrets. Stored per-tenant (money flows to the tenant's own merchant account, so the credentials are tenant-scoped for regulatory reasons).ApplicationUser.AuthenticatorKey— TOTP seedApplicationUser.FirstName,LastName,PhoneNumber,DateOfBirth— PII fields (DateOfBirth stored as ISOstring?like"1990-05-15")
What's not encrypted, on purpose
Email/NormalizedEmail/UserName/NormalizedUserName— Identity uses these for login lookup. Encrypting them breaksFindByEmailAsync.DisplayName— used by the registration uniqueness check (DisplayNameExistsAsync).- WebAuthn passkey
CredentialId/PublicKey,VapidPublicKey— public by design. DomainVerificationToken— deterministic HMAC of the org name + domain, intentionally readable so DNS verification can replay.
Trade-off: queries against encrypted fields don't work
Data Protection produces non-deterministic ciphertext (same plaintext, different output every time). That defeats frequency analysis — but it also means server-side equality checks, substring searches, and sort operations against encrypted fields return nothing useful. The platform users search at /Platform/Users intentionally restricts its filter to the unencrypted fields (DisplayName, UserName, Email) for this reason.
If you ever need exact-match lookups on an encrypted field (e.g. "find user with this exact phone"), the standard pattern is a blind index: store an HMAC of the normalised value alongside the encrypted field and query by HMAC. Be cautious about adding blind indexes on low-entropy fields like first names — they're vulnerable to frequency attacks where an attacker matches hash counts against public name distributions to recover plaintext.
Encryption Mode
A single configuration knob, Security:Encryption:Mode, controls how the BSON serializer behaves. Default is Required. The other two values are escape hatches for local debugging and refuse to start in Production.
"Security": {
"Encryption": {
"Mode": "Required" // or "ReadOnly", "Disabled"
}
}
Required (default)
- Write: values go through
ISecretProtector.Protect. - Read: values go through
Unprotect; bad ciphertext throwsCryptographicException— fail loud. - When to use: always, in every environment, unless you're actively debugging.
ReadOnly (dev only)
- Write: plaintext — the protector is not invoked.
- Read: tries to decrypt; if the value isn't valid ciphertext (e.g. it was just written in this mode), the failure is caught and the raw value is returned.
- When to use: debugging an existing dev database where you want to inspect raw values in Mongo Compass without losing the ability to read existing encrypted fields.
Disabled (dev only)
- Write: plaintext.
- Read: the stored value is returned verbatim, with no decryption attempt. Existing ciphertext therefore comes back as-is — unreadable, but it does not throw. If your dev database already holds encrypted values, use
ReadOnlyinstead. - When to use: a fresh dev database you've never run with encryption on, where you want to inspect everything raw.
⚠️ Production guard: if the environment is anything other than Development and the mode is anything other than Required, startup throws — on every backend (the MongoDB activator and the relational EncryptionModeResolver apply the same rule). The two dev modes also log a startup warning so they're visible in console output. There is no way to silently run with weakened encryption outside development.
The test is deliberately "not Development" rather than IsProduction(): the latter matches only the literal environment name Production, so Staging, a per-region name, or any custom environment would have slipped straight past it.
One-Way Hashing
Some values only need to be verified, never read back. Those are stored as SHA-256 hashes of the original — the raw value is sent to the user once and never stored.
OrganisationMembership.InvitationToken— random token in the invitation email link; only the SHA-256 hash is persisted.AcceptInvitationByTokenAsynchashes the submitted token and looks up by hash. That token is the only route to accepting an invitation — matching a signed-in account's email address against the invited address is deliberately not sufficient, since nothing independently proves control of that mailbox.ApplicationUser.RecoveryCodes— UserManager surfaces plaintext codes once when generated; only hashes go into the database.RedeemCodeAsynchashes the submitted code and removes the matching hash from the list.- Single-use access-link tokens — a feature that hands an unauthenticated visitor a random token in a URL stores only its SHA-256 hash plus an expiry, then constant-time-compares on revisit. The host exposes this as a reusable
ITokenHasherprimitive (Sha256TokenHasher).
A database read alone never reveals a usable token or code — the attacker would need both the database and a way to brute-force the hash, and the values are random enough that brute force isn't practical.
Personal Data & GDPR Export
PII fields on ApplicationUser are tagged with the standard ASP.NET Identity attributes:
[PersonalData]onFirstName,LastName,DateOfBirth,DisplayName,ProfilePictureUrl[ProtectedPersonalData]onAuthenticatorKey,RecoveryCodes
These attributes drive the GDPR data-download endpoint at /Account/Manage/DownloadPersonalData. When a user requests their personal data, the endpoint reflects over the user record and returns a JSON file containing every [PersonalData] value in plaintext (the BSON layer transparently decrypts on read), plus an explicit Authenticator Key entry and a Recovery Codes Remaining count. [ProtectedPersonalData] fields are excluded from the auto-reflection and handled explicitly because their treatment differs (TOTP key needs decryption; recovery codes are hashed and not exportable).
The endpoint is independent of the [Encrypted] attribute — encryption is for storage; [PersonalData] is for export.
Account Security Surfaces
Three things a user can do about their own account's security, all built:
- Active sessions (
/Account/Manage/Sessions) — every sign-in listed with device, IP and last-active time, "this device" marked, and per-session or sign-out-everywhere revocation. A session id is minted into the auth cookie at sign-in and validated per request and per circuit revalidation, so a revoked session stops working across instances rather than only on the one that revoked it. A password change revokes the others to match the security-stamp bump. - Security activity (
/Account/Manage/SecurityActivity) — the user's own paginated history of sign-ins, password and 2FA changes, recovery-code use, external-login and role changes. Backed by a platform-scoped event log that is dual-written from the audit trail so the two cannot drift, and pruned on the audit retention window. Distinct from the tenant-scoped audit log behind the admin views: this one is the user's, that one is the organisation's. - Lockout (
Authentication:Lockout:*) — configurable thresholds on repeated failures.
⚠️ Any limiter guarding an anonymous surface must count in the shared store, not in per-process memory. A per-process counter lets N instances allow N× the attempts, and a load balancer will spread an attacker's requests across them for you. The rate-limit store is Redis-backed when Redis is present and in-memory otherwise, and it is what the existing guards use.
Initial Setup Authorisation
Creating the platform organisation and the first platform admin is the one operation that hands out total control of a deployment, and on a fresh install nobody is signed in yet to authorise it. Two things can, and either alone is enough:
- A trusted connection — the request's real socket peer is loopback, or an address
you named in
Security:Setup:TrustedProxies/:TrustedNetworks, and theHostheader islocalhost,127.0.0.1or::1. Both halves, and the host half cannot be widened by configuration — so this path is for a browser on the box, whatever address the peer arrives from. - A setup token —
Security:Setup:Tokenif you set it, otherwise 256 bits generated per process. Passed as/initial-setup?setupToken=…, compared in constant time, held in memory only and never persisted. The app writes it to the log atWarning, once per process, the first time a setup attempt is refused — so it appears exactly when you need it and never on a deployment that is already set up.
Both stop mattering permanently once an organisation exists: the gate is only reached while there is none.
Why an address alone cannot be the answer
In a container the peer is never loopback. The port is published, so a browser on the host reaches the app through Docker's port forwarding and the app sees the bridge gateway — and so does everyone else who can reach that port. An address cannot separate the operator from a stranger in that topology, which is why the token exists and why trusting the container network is a deliberate choice rather than a default.
The deploy compose files make that choice for you, on the web service:
- "Security__Setup__TrustedNetworks__0=172.16.0.0/12"It keeps first run a single click on the box, and it trusts every caller that can reach the published port until setup completes. Keep it when that port is not reachable from outside the host; delete it — and use the token — when a fresh instance will sit on a public address unattended. The application's own default ships both lists empty.
Reaching a first run over a domain
Use the token — the trusted-network line will not help. Before setup there is no
configured domain to reach: the platform organisation is what setup creates, and its domain comes
off that very form. The host check accepts only localhost, 127.0.0.1 and
::1, so a first run opened at https://app.example.com/initial-setup is
refused however you have set the peer lists.
A valid token satisfies both halves at once, which is exactly what makes this case work: it is a
secret only the operator holds, so it is stronger evidence than either property of the connection.
Either browse the box over localhost and set the domain on the form, or open
/initial-setup?setupToken=… over the domain.
⚠️ An address that arrived in a header is never trusted here. When forwarded-header
processing rewrites the client address it records what it replaced in X-Original-For,
and this gate refuses any request carrying it. Without that, a caller trusted to set
X-Forwarded-For — which behind a published port is everyone, given the
shipped ForwardedHeaders defaults — could send X-Forwarded-For: 127.0.0.1
with Host: localhost and satisfy the whole gate from anywhere.
The Data Protection Key Ring
All field-level encryption is backed by ASP.NET Core Data Protection. The master key ring is what protects every encrypted value in the database. Lose the keys and you lose the data — the ciphertext becomes permanently unreadable.
Where the keys live
Configured by ServiceDefaults.AddDataProtection() in Core/BlazorBlueprint.ServiceDefaults/Extensions/ServiceCollectionExtensions.cs, with a 90-day key lifetime and automatic rotation. Where it is stored depends on your deploy mode:
- Full stack — in Redis, under the list
BlazorBlueprint-DataProtection-Keys. All three hosts (Web, InternalApi, BackgroundWorker) connect to the same Redis with the sameApplicationDiscriminator("BlazorBlueprint"), so they share one ring and can decrypt one another's writes. - Lite — on the filesystem, via a named volume mounted at
/keys. There is no Redis in Lite, so that volume is your key ring, and it is the one Lite volume you cannot afford to lose. Back it up by its host name,bb_lite_dp_keys—dp_keysis only the compose-local alias, so a backup job keyed on that literal matches nothing.
Everything below about rotation, backup and sharing applies to both — only the storage location differs.
The list name is {Caching:RedisKeyPrefix}-DataProtection-Keys. That prefix — which also namespaces the caches, the SignalR backplane and the cache-eviction channel — only needs setting when two deployments share one Redis instance, so that each keeps its own ring instead of writing over the other's. Set it once, at first deploy. Changing it on a running deployment points the app at an empty ring: it mints a fresh key, and every value encrypted under the old ring becomes permanently unreadable.
The prefix is namespacing, not isolation — do not read it as a boundary between two
deployments sharing a Redis. --requirepass authenticates Redis's implicit
default ACL user, and that user's grant is ~* (all keys),
&* (all channels), +@all (all commands). Two deployments sharing an
instance connect as that same user, so either can read the other's key ring — every
[Encrypted] field in the other deployment — and either can FLUSHALL it.
The prefix stops them colliding; it does not stop them reading.
If you must share one Redis across deployments that do not trust each other, give each its own ACL user rather than relying on the prefix:
redis-server --requirepass '<pw>' \
--user default off \
--user bbapp on '>PASSWORD' '~BlazorBlueprint*' '&BlazorBlueprint*' +@all -@admin
…and add user=bbapp to ConnectionStrings__redisCache. The
& channel pattern is needed as well as ~ because the cache
invalidator is pub/sub, @scripting has to stay because the rate limiter is Lua,
and -@admin is safe because the app resolves no IServer and issues
no INFO or CONFIG. It is not wired by default deliberately: on a Redis
serving one deployment it buys nothing over a strong password, and a grant that is too narrow
surfaces as a NOPERM error inside a background job rather than at startup.
Keys are auto-generated by the framework on first use — you never set them by hand. You only configure where they're stored.
Key rotation (automatic)
Data Protection rotates the active key on a fixed schedule — every 90 days by default. Rotation is automatic, transparent, and does not require any action on your part. Here's what happens:
- A few days before the current key expires, a new key is generated and added to the ring.
- From that point on, new writes use the new key.
- Old keys stay in the ring permanently — they're never deleted. This is by design, so existing ciphertext written under any previous key is always decryptable.
- The ciphertext itself carries the ID of the key it was encrypted with, so the framework picks the right key from the ring automatically on read. You don't track this anywhere.
Practical implication: the Redis list at BlazorBlueprint-DataProtection-Keys grows by one entry every ~90 days. Each entry is a few KB of XML. Over a year of running you might have 4–5 keys in the ring. Storage cost is negligible.
Backup implication: back up the ring (the whole Redis list / volume / managed-Redis snapshot), not "the key" — singular. A backup taken today contains every key the app has ever generated; restore that backup at any point in the future and all historical ciphertext is readable. The reason to back up on a regular schedule (daily snapshot of Redis or the host VM) isn't because keys "disappear" — it's so a backup taken today still covers ciphertext written next month with the next rotated key.
The only rotation event you'd ever drive manually is a revocation — explicitly marking a key as compromised via IKeyManager.RevokeKey(...). That stops the key being used for new encryption and renders all ciphertext written under it unreadable. Drastic, deliberate, and not something that happens automatically.
Sharing keys across hosts
Any process that (a) connects to the same Redis and (b) uses the same ApplicationDiscriminator reads the same key ring. That's already how Web + InternalApi + BackgroundWorker share keys today; they all point at the same ConnectionStrings:redisCache.
Scaling out to additional servers? Point them at the same Redis. Done. Different Redis instances generate different key rings and cannot decrypt one another's data.
⚠️ Critical: the Data Protection key ring is as important as your database. Treat it with the same operational care — back it up, monitor it, document its location.
Local Development (Aspire)
The BlazorBlueprint.AppHost Aspire project provisions a Redis container with a persistent volume and snapshotting enabled, so the key ring survives dotnet run cycles, image rebuilds, and machine reboots:
var cache = builder.AddRedis("redisCache")
.WithDataVolume()
.WithPersistence(interval: TimeSpan.FromMinutes(1));
WithDataVolume() creates a named Docker volume on first run; Aspire reuses it on subsequent runs. WithPersistence(...) tells Redis to flush its in-memory state to disk every minute, so a hard crash loses at most ~60 seconds of writes. The only ways to lose the keys locally are docker volume rm on the named volume, reformatting your machine, or deleting the volume from Docker Desktop's UI.
Production Deployment
Both the GitHub Actions and Azure DevOps deploy paths use the same docker-compose.yml shape, with Redis configured for durable storage:
blazorblueprint-redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 2gb
--maxmemory-policy noeviction
--requirepass ${REDIS_PASSWORD:?set REDIS_PASSWORD to a strong password before deploying}
The redis_data named volume persists on the host's Docker volume directory. The --appendonly yes flag enables AOF (append-only file) persistence so every write is durable, not just periodic snapshots.
The other three arguments are not tuning, and none of them is optional.
--requirepass— Redis publishes no host port, but "not published" is not "not reachable": anything else attached to the compose network can talk to it. This instance holds the Data Protection key ring unencrypted, so an unauthenticated Redis is every encrypted field in the deployment, plus the ability to forge auth cookies, plus oneFLUSHALLaway from making all encrypted data permanently unreadable. The${VAR:?message}form makesdocker compose uprefuse to start without the password rather than quietly bringing up an open instance. There is deliberately no username variable —--requirepasssets the password of Redis's implicitdefaultACL user, which is why the connection string carriespassword=and nouser=.--maxmemory-policy noeviction— this instance also carries the job streams. Anyallkeys-*policy evicts them under memory pressure, which loses queued work silently: no error, no log line, just background jobs that never run.--maxmemory 2gb— the ceiling that policy applies at. Undernoeviction, reaching it makes Redis refuse writes rather than evict, which stops every publish and takes the cache, the SignalR backplane and the key ring with it. Size it against the worst worker outage you want to survive, and alert on it.
What survives a deploy, what doesn't
- ✅ New image pushed → server pulls + restarts containers — named volumes are not touched. Keys persist.
- ✅
docker compose down && docker compose up -d— volumes survive a regular down/up cycle. - ✅ Server reboots — Docker remounts named volumes on restart.
- ❌
docker compose down -v— the-vflag removes volumes. Wipes Redis keys and Mongo data. - ❌
docker volume rm redis_data— explicit volume deletion. - ❌ Server's disk fails or VM is destroyed — same risk as your MongoDB.
Backup strategy
The simplest answer: snapshot the host VM. Whatever's running your Docker host (Azure VM, AWS EC2, DigitalOcean, Hetzner, etc.) almost certainly has a "snapshot" or "backup" feature in its console. Schedule daily snapshots, retain ~7 days. That captures the whole disk including all Docker volumes — Redis keys, Mongo data, n8n data — in one operation.
For more granular backup of just the key ring volume:
docker run --rm \
-v redis_data:/data:ro \
-v $(pwd):/backup \
busybox tar czf /backup/redis-$(date +%F).tar.gz -C /data .
Copy the resulting tarball off the server (S3, Azure Blob, etc.). Restore by extracting it back into the same volume location. Note the :ro — mount the key-ring volume READ-ONLY for a backup. An ad-hoc container given write access to a live data volume can rewrite it, and for this volume that means losing the key ring, which is the exact loss the backup exists to prevent.
🚨 Never run docker compose down -v in production. The -v flag is destructive and irreversible. Stick to docker compose down (no flag) and docker compose up -d for normal restarts.
Optional Hardening
The default setup is appropriate for most SaaS applications. The following upgrades are worth considering as the application grows or compliance requirements emerge:
- Encrypt the keys-in-Redis with
ProtectKeysWithCertificate(...)— not wired up by default; a suggestion comment marks where it would go, insideAddDataProtection()inServiceCollectionExtensions.cs. The keys inside Redis become themselves encrypted by a certificate you control. If Redis is dumped, the contents are useless without the cert. Shifts your "thing to keep safe" from "all of Redis" to one X.509 cert in your secrets manager. - Move the key ring to Azure Key Vault / AWS KMS (
PersistKeysToAzureBlobStorage+ProtectKeysWithAzureKeyVault, or AWS equivalents). Keys live in a managed KMS, multiple regions can read them with appropriate IAM, audit logs are built in. Heavier ops setup. - Mount the key ring on a network file share (
PersistKeysToFileSystemon EFS / Azure Files / NFS). Useful if you want to keep the keys outside Redis but don't want to introduce cloud KMS dependencies. - Storage-layer encryption-at-rest for MongoDB (Atlas built-in EAR, encrypted EBS volumes, encrypted backups). Protects against an attacker who gets a raw disk image but not against one with database credentials. Transparent — doesn't break any query — and complements field-level encryption rather than replacing it.
Quick Reference
Adding a new encrypted field
- Make sure the property type is
string(the convention throws at startup for non-strings). - Add
[Encrypted]fromBlazorBlueprint.Domain.Attributes. - That's it. No service-layer changes, no migration needed for new data.
Existing plaintext rows written before the attribute was added will throw CryptographicException on read — by design, so misconfiguration fails loud rather than silently returning ciphertext as plaintext. For a fresh database this is a non-issue. If you're retrofitting, add a one-time migration that reads + re-saves affected documents.
Key file locations
Core/BlazorBlueprint.Domain/Attributes/EncryptedAttribute.cs— the attributeInfrastructure/BlazorBlueprint.Infrastructure.Persistence.MongoDB/Encryption/— BSON serializer, convention, host (MongoDB backend)Infrastructure/BlazorBlueprint.Infrastructure.Persistence.EntityFrameworkCore/Encryption/— value converter + mode resolver (PostgreSQL / SQL Server backends)Core/BlazorBlueprint.Application/Services/Security/DataProtectionSecretProtector.cs— protector implementationCore/BlazorBlueprint.ServiceDefaults/Extensions/ServiceCollectionExtensions.cs— Data Protection / Redis wiring
Ready to Build?
Ship with secrets handled correctly out of the box. The complete source is free to use — including commercially — while your business earns under £100k/year. Past that, a one-time commercial licence (from £499, excl. VAT) applies. Every tier ships the same product; nothing is feature-gated.
🔓 Source-available • Free under £100k/yr revenue • Commercial licence (from £499) above that • Full source code included