BlogArray.SaaS is an open-source multi-tenant SaaS platform designed to empower .NET developers to build, deploy, and manage scalable SaaS applications effortlessly. Built with ASP.NET Core 10, this platform leverages OpenIddict for identity management and Finbuckle.MultiTenant for multi-tenant support. It provides developers with a foundational solution for handling tenant management, authentication, authorization, and tenant-specific functionality, saving time and reducing complexity.
The platform consists of three main applications:
- BlogArray.SaaS.Identity: An identity server built on top of OpenIddict.
- BlogArray.SaaS.TenantSuite: A management application for tenants, users, roles, and scopes.
- BlogArray.SaaS.App: A demonstration of multi-tenant functionality. This application only supports Multiple Database - Complete Data Isolation. Feel free to customize the app for your desired approach.
Note: The project is currently in Proof of Concept (PoC) mode, so there may be occasional mistakes. Contributions and feedback are welcome.
- Multi-Tenant Support: Seamlessly manage multiple tenants using Finbuckle.MultiTenant.
- Identity Management: Built-in identity server leveraging OpenIddict for authentication and authorization.
- Scalability: Designed to support scalable SaaS applications.
- Flexibility: Easily customizable for different business needs.
- Caching: Supports SQL Server or Redis for optimized performance.
- Tenant-Specific Media Storage: Save tenant-specific media files securely using Azure Blob Storage.
- ASP.NET Core 10
- OpenIddict
- Finbuckle.MultiTenant
- Entity Framework Core
- SQL Server / Redis (for caching)
- Azure Blob Storage (for tenant-specific media storage)
To get started with BlogArray.SaaS, follow these steps:
Ensure you have the following installed:
- .NET SDK 10
- SQL Server or Redis
- Azure Storage Account (for media storage)
- Node.js (if using frontend integrations)
- Git
- Visual Studio 2022 with the ASP.NET and web development workload installed
-
Clone the Repository
git clone https://github.com/BlogArray/SaaS.git
-
Navigate to the Project Directory
cd BlogArray/SaaS -
Restore Dependencies
dotnet restore
-
Configure the Application Update the
appsettings.jsonfile in each application directory with the following configurations:{ "AllowedHosts": "*", "IPSafeList": "127.0.0.1;192.168.1.5;::1", "ConnectionStrings": { "IdentityContext": "Data Source=.;Initial Catalog=BlogArray.SaaS.Identity;User Id=sa; Password=welcome;TrustServerCertificate=True;MultipleActiveResultSets=True" }, "AzureBlobStorage": { "ConnectionString": "UseDevelopmentStorage=true", "ContainerName": "multi-tenant" }, "Cache": { "Type": "SqlServer", //SqlServer or Redis "ConnectionString": "Data Source=.;Initial Catalog=DistCache;User Id=sa; Password=welcome;TrustServerCertificate=True;MultipleActiveResultSets=True", "SlidingExpirationInMinutes": 30, "AbsoluteExpirationInHours": 6 }, "Links": { "Suite": "https://www.console.blogarray.dev/", "Identity": "https://www.id.blogarray.dev/", "Admin": "https://www.admin.blogarray.dev/", "Issuer": "https://www.id.blogarray.dev/", "Authority": "https://www.id.blogarray.dev/" }, "SMTP": { "FromEmail": "noreply@app.com", "FromName": "App Development", "Username": "localhost", "Password": "ttczmtxemkinbzxv", "Host": "localhost", "Port": 587, "EnableSsl": false }, "Defaults": { "DefaultLogoUrl": "https://www.id.blogarray.dev/_content/BlogArray.SaaS.Resources/resources/images/blogarray-full-logo.png", "DefaultFaviconUrl": "https://www.id.blogarray.dev/_content/BlogArray.SaaS.Resources/resources/images/blogarray-icon.png" } } -
Create the OpenIddict Applications Seeding File The Identity application seeds OpenIddict clients from
OpenIddictApplications.json. This file is not committed to the repository because it can contain secrets. Copy the provided template insrc/Apps/BlogArray.SaaS.Identity/:cd src/Apps/BlogArray.SaaS.Identity cp OpenIddictApplications.template.json OpenIddictApplications.jsonThe
ClientSecretfield is optional: when left empty, a cryptographically random client secret and API key are generated server-side at seeding time (retrieve them from the tenant administration console). Never commit the realOpenIddictApplications.jsonfile. -
Apply Migrations Run the following command in each application directory that uses a database:
dotnet ef database update
-
Update Hosts File To enable a real-time experience, update the
hostsfile atC:\Windows\System32\drivers\etc\hostswith the following entries:127.0.0.1 blogarray.dev 127.0.0.1 www.blogarray.dev 127.0.0.1 app.blogarray.dev 127.0.0.1 www.app.blogarray.dev 127.0.0.1 id.blogarray.dev 127.0.0.1 www.id.blogarray.dev 127.0.0.1 console.blogarray.dev 127.0.0.1 www.console.blogarray.dev 127.0.0.1 admin.blogarray.dev 127.0.0.1 www.admin.blogarray.dev 127.0.0.1 auth.blogarray.dev 127.0.0.1 www.auth.blogarray.dev -
Run Multiple Applications in Visual Studio
- Open the
BlogArray.SaaS.slnxsolution in Visual Studio. - Set multiple startup projects by:
- Right-click the solution in Solution Explorer and select Properties.
- In the Common Properties -> Startup Project tab, choose Multiple startup projects.
- Set the Action to
StartforBlogArray.SaaS.Identity,BlogArray.SaaS.TenantSuite, andBlogArray.SaaS.App. - Click OK.
- Press F5 to run all applications simultaneously. Each application will launch in its configured domain.
- Open the
-
Multi-Tenant Configuration: Define tenants in the
appsettings.jsonof theTenantSuiteapplication. -
Identity Server: Configure client secrets and scopes in
BlogArray.SaaS.Identity. -
Caching: Enable and configure either SQL Server or Redis for caching in the
appsettings.json. -
Azure Blob Storage: Configure the Azure Blob Storage connection string and container name in
appsettings.jsonfor tenant-specific media storage. -
Multi-Tenant Strategy Configuration: BlogArray.SaaS uses Finbuckle.MultiTenant's
Routestrategy for tenant identification by default. You can customize the strategy as per your requirements by referring to the Finbuckle.MultiTenant Documentation. Example of switching to theHoststrategy:builder.Services.AddMultiTenant<AppTenantInfo>() .WithHostStrategy() .WithDistributedCacheStore(TimeSpan.FromMinutes(5)) .WithPerTenantAuthentication();
Refer to the documentation for more details and supported strategies.
-
CAPTCHA (Cloudflare Turnstile): Set
Captcha:SiteKeyandCaptcha:SecretKeyin the Identity application to enable the challenge on login, forgot/reset password, resend-confirmation and recovery-code pages. Empty keys (default) disable it. -
CORS: Set
Cors:AllowedOrigins(semicolon-separated) on any application that must accept browser requests from another origin. Empty (default) = no cross-origin access. -
Tenant SQL host allow-list: Set
Tenants:AllowedSqlHosts(semicolon-separated) on TenantSuite/Identity to restrict which SQL Server hosts tenant connection strings may target. Empty (default) = any host (development only). -
Passkey origins: Set
Fido2:Origins(semicolon-separated) in the Identity application to accept additional origins for passkey ceremonies beyondLinks:Issuer. -
Password policy extras:
Passwords:HistorySize(remembered previous passwords, default 5) andPasswords:BlockBreachedPasswords(reject passwords found in known data breaches, default true) in the Identity/TenantSuite applications.
The platform ships with a hardened authentication stack: passkeys (WebAuthn) as a full passwordless sign-in method, email one-time codes as a second factor, CAPTCHA step-up, per-device session management with single sign-out, tenant-bound API keys, and SAML SSO with spec-depth assertion validation. This section covers the configuration and behaviors you should know about.
The seeded admin@blogarray.net Superuser account ships without a password and is flagged to change its password at first sign-in. Set the initial password through either:
- the Forgot password flow (requires a working email sender), or
- the TenantSuite user management Reset password action ("create a temporary password on behalf"), which forces the user to set a new password at the next sign-in.
The Identity application seeds OIDC clients from OpenIddictApplications.json. This file is not committed (it carries environment-specific client secrets and is gitignored) - copy the tracked template on a fresh clone:
cp src/Apps/BlogArray.SaaS.Identity/OpenIddictApplications.template.json src/Apps/BlogArray.SaaS.Identity/OpenIddictApplications.json- When the file is absent, seeding is skipped with a startup warning instead of failing.
Credential handling:
- Keep development secrets only in this file - never reuse them in production.
- When
ClientSecretis omitted, a cryptographically random secret and API key are generated server-side at seeding time (retrieve them from the tenant administration console). - Rotate any secret that has ever been committed to a public repository.
The Membership API (api/membership) resolves the tenant from the presented X-API-Key and rejects requests (HTTP 403) whose body names a different tenant. One tenant's API key can no longer invite, assign, or remove users in another tenant.
The Identity application signs and encrypts tokens with X.509 certificates. Configure them per environment:
- Local development (Windows): self-signed certificates in the current user's certificate store, referenced by thumbprint in
appsettings.Development.json. - Production: the same certificates imported into the server's
LocalMachine\Mystore (or CA-issued certificates), referenced in the production configuration.
$notAfter = (Get-Date).AddYears(10)
$signing = New-SelfSignedCertificate -Subject "CN=BlogArray.SaaS Token Signing" `
-FriendlyName "BlogArray.SaaS Token Signing" `
-KeyAlgorithm RSA -KeyLength 4096 -KeyExportPolicy Exportable `
-KeyUsage DigitalSignature -KeySpec Signature `
-NotAfter $notAfter -CertStoreLocation "Cert:\CurrentUser\My"
$encryption = New-SelfSignedCertificate -Subject "CN=BlogArray.SaaS Token Encryption" `
-FriendlyName "BlogArray.SaaS Token Encryption" `
-KeyAlgorithm RSA -KeyLength 4096 -KeyExportPolicy Exportable `
-KeyUsage KeyEncipherment,DataEncipherment -KeySpec KeyExchange `
-NotAfter $notAfter -CertStoreLocation "Cert:\CurrentUser\My"
"Signing: $($signing.Thumbprint)"
"Encryption: $($encryption.Thumbprint)"Back up the certificates as PFX files (store these somewhere safe - never commit them):
$certsDir = "src\Apps\BlogArray.SaaS.Identity\certs" # this folder is gitignored
New-Item -ItemType Directory -Force -Path $certsDir | Out-Null
$passwordChars = 1..48 | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) }
$pfxPassword = ConvertTo-SecureString -String (-join $passwordChars) -Force -AsPlainText
Export-PfxCertificate -Cert $signing -FilePath "$certsDir\blogarray-token-signing.pfx" -Password $pfxPassword
Export-PfxCertificate -Cert $encryption -FilePath "$certsDir\blogarray-token-encryption.pfx" -Password $pfxPasswordReference the certificates by thumbprint. For local development, add to appsettings.Development.json:
{
"OpenIddict": {
"SigningCertificate": {
"Thumbprint": "<signing thumbprint from above>"
},
"EncryptionCertificate": {
"Thumbprint": "<encryption thumbprint from above>"
}
}
}Thumbprintsearches the CurrentUser and LocalMachineMycertificate stores.Path+Passwordloads a PFX file instead (useful on servers where you prefer file-based keys).
When both certificates are configured, access tokens are also encrypted. Without certificates the server falls back to ephemeral keys and prints a CRITICAL warning: tokens are invalidated on every restart and this is not safe for multi-instance deployments.
The IIS application pool runs under a different account and may not see your user's CurrentUser store. Run one elevated PowerShell to make the certificates machine-wide:
# Run this PowerShell as Administrator
Move-Item "Cert:\CurrentUser\My\<SIGNING THUMBPRINT>" "Cert:\LocalMachine\My"
Move-Item "Cert:\CurrentUser\My\<ENCRYPTION THUMBPRINT>" "Cert:\LocalMachine\My"(The application pool reads LocalMachine\My without extra permissions for standard machine keys.)
- Copy the two PFX backups to the server (via your secret-management process).
- Import them into the machine store:
Import-PfxCertificate -FilePath .\blogarray-token-signing.pfx `
-CertStoreLocation Cert:\LocalMachine\My `
-Password (Read-Host -AsSecureString "PFX password")
Import-PfxCertificate -FilePath .\blogarray-token-encryption.pfx `
-CertStoreLocation Cert:\LocalMachine\My `
-Password (Read-Host -AsSecureString "PFX password")- Grant the application pool's identity read access to the private keys:
foreach ($thumb in @("<SIGNING THUMBPRINT>", "<ENCRYPTION THUMBPRINT>")) {
$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object Thumbprint -eq $thumb
$key = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
$uniqueName = if ($key.Key.GetType().Name -eq "RSACng") { $key.Key.UniqueName } else { $key.CspKeyContainerInfo.UniqueKeyContainerName }
$folder = if ($key.Key.GetType().Name -eq "RSACng") { "$env:ProgramData\Microsoft\Crypto\SystemKeys" } else { "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys" }
icacls "$folder\$uniqueName" /grant "IIS_IUSRS:(R)"
}- Fill in the thumbprints in the production configuration and restart the application.
Self-signed certificates are acceptable for token signing because the relying party's trust is pinned to the certificate itself (via the security.txt/discovery JWKS), not to a CA chain. Renew or replace before the 10-year validity ends.
PersonnelsController in BlogArray.SaaS.App (which creates identity users and grants tenant access) now requires the TenantAdmin or Superuser role. Grant users the TenantAdmin role in the tenant suite before they can manage personnel.
API keys are never stored in plaintext: validation compares a SHA-256 hash, tenant apps read a DataProtection-protected copy, and only a short display prefix is shown in the admin UI. Tenant credentials (client secret and API key) are emailed to the tenant admin addresses on creation and on API key rotation; a delivery failure never blocks the operation because the secrets are also shown once in the browser.
| Setting | Purpose |
|---|---|
ApiKey:PrefixLength |
Number of leading key characters kept for display (default 8). Change per environment without affecting already-stored keys. |
DataProtection:Mode |
Local (self-hosted/IIS, ring persisted in the master database) or AzureKeyVault (App Service/multi-instance, uses BlobUri plus optional KeyVaultKeyId). |
DataProtection:BlobUri |
Azure blob URI persisting the key ring in AzureKeyVault mode. See the Azure App Service section below. |
DataProtection:KeyVaultKeyId |
Optional version-less Key Vault key URI encrypting the persisted ring at rest (used in AzureKeyVault mode). |
DataProtection:KeyLifetimeDays |
Days a generated key protects new payloads before DataProtection rolls to a fresh one (default 90). Expiration never affects decryption: expired keys stay in the ring forever, so already-protected payloads keep unprotecting. |
In Local mode the ring is stored in the master database's DataProtectionKeys table (created by the AddDataProtectionKeys migration; EnsureCreated covers brand-new databases). There is no key file to create and no folder/ACL setup: the ring is generated automatically on first use, is shared by all three apps through the shared database, and is backed up together with the regular database backups. DataProtection rotates keys automatically every 90 days and keeps the old ones for decryption, so backups stay valid across rotations.
The key ring is never stored on local disk.
Localmode persists it to the master database andAzureKeyVaultmode to Azure blob storage (optionally Key Vault-encrypted) - the database/storage backup is therefore the only backup that matters. Never delete old rows from the ring store: expired keys are retained for decryption by design, and removing them makes payloads encrypted with those keys unreadable.
App Service's built-in DataProtection persistence (%HOME%\data\.aspnet\DataProtection-Keys) is per app - Identity, TenantSuite and App would each get their own ring and could not decrypt each other's payloads. All three apps must therefore share one explicit store.
Recommended - Mode: Local (database ring, zero extra infrastructure):
All three apps already share the master database, so the DataProtectionKeys table is a shared, backed-up ring out of the box on App Service. Just leave DataProtection:Mode at Local (or set DataProtection__Mode = Local) - no mounts, no blob, no Key Vault. Back up the database as usual.
Optional - Mode: AzureKeyVault (ring outside the database, encrypted by Key Vault):
- Create a storage account blob container (e.g.
dataprotection) and a Key Vault key (version-less). - Choose authentication:
- Managed identity: enable a system-assigned identity on all three App Services and grant each Storage Blob Data Contributor on the storage account and Key Vault Crypto User on the vault. Set
DataProtection__BlobUrito the plain blob URI (e.g.https://<account>.blob.core.windows.net/dataprotection/keys.xml). - SAS: set
DataProtection__BlobUrito the blob URI with a SAS token in its query string (no roles needed).
- Managed identity: enable a system-assigned identity on all three App Services and grant each Storage Blob Data Contributor on the storage account and Key Vault Crypto User on the vault. Set
- Optional but recommended - set
DataProtection__KeyVaultKeyIdto the version-less key URI (e.g.https://<vault>.vault.azure.net/keys/dataprotection) so keys are encrypted at rest with Key Vault. - Set the app settings on all three apps:
DataProtection__Mode = AzureKeyVault
DataProtection__BlobUri = https://<account>.blob.core.windows.net/dataprotection/keys.xml
DataProtection__KeyVaultKeyId = https://<vault>.vault.azure.net/keys/dataprotection
ConfigureBlogArrayServices fails fast at startup when Mode is AzureKeyVault but BlobUri is missing.
Upgrade note: the tenant secrets (client secret, connection string) are stored DataProtection-protected; the plaintext columns were dropped. Two-step upgrade: deploy the commit that adds the protected columns and the startup conversion sweep first, let every app start once (the sweep converts existing rows), then deploy the commit that drops the plaintext columns. Jumping straight to the final schema loses pre-existing client secrets and connection strings - re-enter them from the tenant administration screens (and rotate the API key if needed).
| Method | Description |
|---|---|
| Password + 2FA | Email/password sign-in with TOTP authenticator, recovery codes, or an emailed one-time code as the second factor. |
| Passkeys (WebAuthn) | Full passwordless sign-in: register a passkey in Settings → Passkeys, then use the native browser/OS prompt (biometric/PIN) from the login page. Passkeys use discoverable credentials with required user verification and are independent of traditional 2FA and its enable/disable state. |
| SAML SSO (per tenant) | Tenants with SSO enabled delegate sign-in to their own identity provider. SAML responses are validated for signature, audience, recipient, request correlation (InResponseTo) and expiry. |
SAML note: encrypted assertions are not supported. Configure the tenant identity provider to issue plain (unsigned-encryption-off) assertions; encrypted assertions are rejected with an error. Adding support is tracked in the backlog (per-tenant encryption certificate + decryption support). | External/social providers | Microsoft, Google, GitHub and Apple, each enabled via
Authentication:*:Enabledflags. 2FA is never bypassed for social sign-ins. |
All sign-ins are recorded in Settings → Security activity.
Every application sign-in creates a tracked session (device, browser, IP). Users can review and revoke their sessions under Settings → Where you're signed in, including signing out individual devices or all other devices - revoked sessions are rejected server-side on the next request.
Logging out of the Identity application revokes all tokens for the user and, for tenants with Single logout enabled, signs the user out of the connected tenant applications.
Set Captcha:SiteKey and Captcha:SecretKey in the Identity application to enable the Turnstile challenge on the login page and on the "email me a code" request during two-factor sign-in. Empty keys (default) disable it entirely. Verification fails open if Cloudflare is unreachable, so an outage cannot block sign-ins.
- BlogArray.SaaS.Identity: Provides authentication and token issuance.
- BlogArray.SaaS.TenantSuite: Manage tenants, users, roles, and scopes.
- BlogArray.SaaS.App: Demonstrates tenant-specific functionality. This application only supports Multiple Database - Complete Data Isolation.
Run each application individually or all together using Visual Studio.
We welcome contributions to improve BlogArray.SaaS! To contribute:
- Fork the repository.
- Create a new branch:
git checkout -b feature/your-feature-name. - Commit your changes:
git commit -m 'Add your feature'. - Push the branch:
git push origin feature/your-feature-name. - Open a pull request.
For detailed guidelines, see the CONTRIBUTING.md.
This project is licensed under the MIT License.
Special thanks to the creators and maintainers of:
We hope BlogArray.SaaS helps you kickstart your SaaS development journey. If you have any questions or encounter issues, feel free to open an issue in the repository!