Internal API authentication¶
MatStream Core API and Tenant API communicate using asymmetric ECDSA P-256 request signatures. They do not share an internal secret.
This is service-to-service authentication. It is separate from the workspace API keys created under Administration > API keys for external integrations.
Key ownership¶
A customer-hosted Tenant must be able to verify Core without receiving a secret that could impersonate Core. Every service therefore owns a separate key pair:
- Core private key stays on Core and signs Core-to-Tenant requests.
- Core public key is installed on each Tenant and verifies Core signatures.
- Tenant private key stays on that Tenant and signs Tenant-to-Core requests.
- Tenant public key is stored in CoreDb and verifies that Tenant.
Core has one key pair. Each independently deployed Tenant has one key pair. With one Core and two Tenant deployments, there are three key pairs in total.
Private keys are never exchanged. Compromising one Tenant key cannot be used to impersonate Core or another Tenant deployment.
How a request works¶
- The caller calculates a SHA-256 hash of the request body.
- It signs the request details with its private key.
- The receiver selects a public key using the signed service ID and key ID.
- The receiver verifies the signature, audience, timestamp, nonce, route, query, method, and body hash.
- Core obtains the Tenant deployment ID from the trusted key registration and limits workspace access to that deployment.
- A valid request continues. Invalid authentication returns
401; cross-deployment workspace access returns403.
Signatures are short-lived and each nonce can be accepted only once. HTTPS is still required because signatures do not encrypt request content.
Generate key pairs¶
Generate keys outside the source repository. Protect private files with operating-system permissions or a secrets manager.
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out core-private.pem
openssl pkey -in core-private.pem -pubout -out core-public.pem
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out tenant-private.pem
openssl pkey -in tenant-private.pem -pubout -out tenant-public.pem
Use unique keys and identities for development, test, production, and every independently hosted Tenant.
CoreDb deployment registry¶
Apply these CoreDb migrations from the API repository in order:
MatStream.Core.API/Migrations/20260803_AddTenantDeploymentSigning.sqlMatStream.Core.API/Migrations/20260803_RemoveLegacyWorkspaceDeploymentColumns.sql
The final normalized structure is:
| Storage | Purpose |
|---|---|
tbl_TenantDeployment |
Tenant identity, expected audience, API base URL, hosting scope, enabled state |
tbl_TenantDeploymentKey |
Public keys, key IDs, validity dates, enabled state, and revocation date |
tbl_Workspace.W_TenantDeploymentID |
Foreign key assigning each workspace to one Tenant deployment |
Core loads Tenant public keys and outbound audiences from CoreDb. Adding or rotating a Tenant therefore does not change Core's appsettings.json and does not require a Core deployment.
tbl_Workspace intentionally does not store a Tenant API URL or hosting-server key. Many workspaces can reference one deployment, and Core obtains TD_TenantApiBaseUrl and TD_HostingServerKey through W_TenantDeploymentID. API responses can still contain TenantApiBaseUrl; it is a joined value rather than duplicated workspace data.
Register a Tenant deployment and its public key in one transaction:
BEGIN TRANSACTION;
INSERT dbo.tbl_TenantDeployment
(TD_ServiceID, TD_Audience, TD_TenantApiBaseUrl, TD_HostingServerKey, TD_Enabled)
VALUES
('tenant-customer-production',
'matstream-tenant-customer-production',
'https://tenant.customer.example',
'customer-production-01',
1);
DECLARE @TenantDeploymentID int = CONVERT(int, SCOPE_IDENTITY());
INSERT dbo.tbl_TenantDeploymentKey
(TDK_TenantDeploymentID, TDK_KeyID, TDK_PublicKeyPem, TDK_Enabled)
VALUES
(@TenantDeploymentID,
'tenant-customer-2026-01',
N'-----BEGIN PUBLIC KEY-----
...contents of tenant-public.pem...
-----END PUBLIC KEY-----',
1);
UPDATE dbo.tbl_Workspace
SET W_TenantDeploymentID = @TenantDeploymentID,
W_ModifiedUTC = SYSUTCDATETIME()
WHERE WorkspaceID = 123;
COMMIT TRANSACTION;
Only the public key belongs in CoreDb. Never put tenant-private.pem in CoreDb.
The removal migration tries to bind legacy workspaces by their previous URL or hosting key. It stops without dropping columns if any workspace still has no deployment ID, allowing those workspaces to be assigned safely first.
New workspaces use the enabled deployment whose TD_TenantApiBaseUrl matches Core's AppSettings:TenantApiBaseUrl. Workspace creation fails safely if the default deployment is not registered.
Core configuration¶
Core configuration contains its identity and private key only:
{
"InternalAuth": {
"ServiceId": "matstream-core",
"ExpectedAudience": "matstream-core",
"KeyId": "core-2026-01",
"PrivateKeyPath": "/run/secrets/core-private.pem",
"SignatureLifetimeSeconds": 60,
"AllowedClockSkewSeconds": 30,
"ReplayRetentionSeconds": 180
}
}
Core does not configure its own PublicKeyPem or PublicKeyPath. Its public key is distributed to Tenant deployments.
Tenant configuration¶
A Tenant owns its private key and receives Core's public key:
{
"InternalAuth": {
"ServiceId": "tenant-customer-production",
"ExpectedAudience": "matstream-tenant-customer-production",
"DefaultTargetAudience": "matstream-core",
"KeyId": "tenant-customer-2026-01",
"PrivateKeyPath": "/run/secrets/tenant-private.pem",
"TrustedSigners": [
{
"ServiceId": "matstream-core",
"KeyId": "core-2026-01",
"PublicKeyPath": "/run/config/core-public.pem"
}
]
}
}
PrivateKeyPem and PublicKeyPem can be used instead of paths when a secrets manager injects PEM content. Environment variables use double underscores, for example InternalAuth__PrivateKeyPath.
MatStream uses a fully managed ECDSA implementation to parse and use these PEM keys. It does not import them into the Windows CNG key store, so IIS application pools do not need Load User Profile or cryptographic-profile permissions for internal API signing.
When a PEM is stored inline in JSON, line breaks must be written as \n with a backslash. /n with a forward slash corrupts the key and causes an ASN1 corrupted data error.
The key material is distributed as follows:
| Key file | Destination |
|---|---|
core-private.pem |
Core InternalAuth:PrivateKeyPem or PrivateKeyPath |
core-public.pem |
Each Tenant's Core entry under TrustedSigners |
tenant-private.pem |
That Tenant's InternalAuth:PrivateKeyPem or PrivateKeyPath |
tenant-public.pem |
CoreDb tbl_TenantDeploymentKey.TDK_PublicKeyPem |
Never place a private key in tbl_TenantDeploymentKey.
Add another Tenant deployment¶
A second workspace on the same Tenant API continues to use the same deployment row and key pair. A separately hosted Tenant API needs:
- A unique
TD_ServiceID,TD_Audience, API base URL, and hosting key. - Its own ECDSA key pair and
TDK_KeyID. - A new
tbl_TenantDeploymentrow and public-key row. W_TenantDeploymentIDassigned to every workspace hosted by it.
Using the same value for TD_ServiceID and TD_Audience is the recommended naming convention. The Tenant's InternalAuth:ServiceId must match TD_ServiceID, and its ExpectedAudience must match TD_Audience exactly.
Rotate a Tenant key¶
- Generate a new Tenant key pair and choose a new
KeyId. - Insert the new public key into
tbl_TenantDeploymentKeyfor the existing deployment. - Change the Tenant's private key and
KeyId. - Confirm signed requests work with the new key.
- Disable and revoke the old database key row.
Core caches registry records for at most one minute. Keep both keys active during a planned transition, and account for the cache delay during emergency revocation. Never replace key material while reusing a KeyId.
Core key rotation works in the opposite direction: install the new Core public key on every Tenant first, then switch Core to the new private key and KeyId.
Troubleshooting¶
Internal endpoint returns 401¶
Check that:
- CoreDb contains an enabled, non-revoked Tenant key for the exact
ServiceIdandKeyId. - The sender's private key matches the stored public key.
- The signed audience matches the receiver's
ExpectedAudience. - Both servers have synchronized clocks.
- A retry is not reusing an already accepted signature and nonce.
Internal endpoint returns 403¶
Authentication succeeded, but the workspace is assigned to another deployment. Check tbl_Workspace.W_TenantDeploymentID.
Core cannot sign an outbound Tenant request¶
Confirm that the target URL matches an enabled TD_TenantApiBaseUrl and that Core has InternalAuth:PrivateKeyPath or InternalAuth:PrivateKeyPem.
Requests fail with multiple API instances¶
The default replay store is process-local. Replace IInternalSignatureReplayStore with shared storage such as Redis or a database so all instances see used nonces.
Security rules¶
- Never send Core's private key to a Tenant.
- Never send a Tenant private key to Core; Core needs only its public key.
- Never commit private PEM files or inline private key configuration.
- Keep HTTPS enabled and server clocks synchronized.
- Revoke a key immediately if its private part may be exposed.
- Do not log private keys, full signature headers, or nonces.
Related¶
- API keys - user-managed credentials for external integrations
- Coding agreements - MatStream development conventions
- AI Assistant - how MatStream AI uses this documentation