The API key feature provides non-interactive authentication for integrations that need to call the application without using the browser login flow.
In the current starter implementation, API keys support:
- one-time key generation with hashed storage
- admin-side key creation, listing, and revocation
- optional expiration dates
- stable permission snapshots stored on the key
- optional organization association in the data model and admin UI
- authentication through the
X-Api-Keyheader - last-used tracking
This is an outbound client credential feature for calling this application. It is not an OAuth client-credentials server and it does not currently provide key rotation, key editing, or partial secret reveal after creation.
The starter includes API keys so new projects can support:
- server-to-server integrations
- automation scripts
- internal tooling
- background sync jobs
- controlled access without sharing a user password
The goal is to give teams a simple, database-backed machine authentication model that is good enough for a starter project and can be extended later if needed.
The API key stack is split across these parts:
| Area | Responsibility | Main files |
|---|---|---|
| Data model | Stores key metadata, hash, scope snapshot, and revocation state | src/Server/Database/Core/Models/ApiKey.cs |
| EF configuration | Configures indexes, JSON storage, and relationships | src/Server/Database/Core/ApplicationDbContext.cs |
| Query layer | Search, sort, paging, and lookup by key hash or creator | src/Server/Database/Core/Data/Queries/BasicsImplementation/CoreApiKeyQuery.cs |
| Decorators | Maps DB entity to shared admin/service model | src/Server/Database/Core/Data/Decorators/ApiKeyDecorators.cs |
| Shared DTOs | Validation rules and create-result contract | src/Shared/Model/ApiKeys/ApiKeyItem.cs |
| Admin service | Creates, lists, and revokes keys | src/Server/Admin/Services/ApiKeys/ApiKeyAdminService.cs |
| Auth handler | Authenticates requests from X-Api-Key |
src/Server/Admin/WebService/Identity/ApiKeyAuthenticationHandler.cs |
| Permission transformation | Converts the key’s stored scopes into permission claims | src/Server/Admin/Services/Authentication/PermissionClaimsTransformation.cs |
| Admin UI | Lets admins manage keys | src/Server/Admin/WebService/UI/Pages/Admin/ApiKeysPage.razor |
| Seeder | Backfills legacy empty-scope keys to stable snapshots | src/Server/Admin/Services/Seeding/ApiKeyDataSeeder.cs |
The runtime flow is:
- A client sends
X-Api-Key: <plain-text-key>. - The smart auth selector routes the request to the API key scheme.
ApiKeyAuthenticationHandlerhashes the supplied key and looks up the matching record.- The handler rejects revoked, expired, inactive, or owner-inactive keys.
- The authenticated principal is created for the owning
ApplicationUser. PermissionClaimsTransformationloads the key’s stored scopes and emitsPermissionclaims from that list.- Authorization policies continue to work through the normal permission system.
The persisted entity is ApiKey.
public class ApiKey : DatabaseEntityObject
{
public string Name { get; set; } = string.Empty;
public string KeyHash { get; set; } = string.Empty;
public string Prefix { get; set; } = string.Empty;
public List<string>? Scopes { get; set; }
public Guid? OrganizationId { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime? LastUsedAt { get; set; }
public bool IsRevoked { get; set; }
public DateTime? RevokedAt { get; set; }
public Organization? Organization { get; set; }
}| Field | Type | Meaning |
|---|---|---|
Name |
string |
Human-readable label for admins. |
KeyHash |
string |
SHA-256 hash of the plain-text API key. The raw key is not stored. |
Prefix |
string |
Short visible identifier shown in the admin UI. |
Scopes |
List<string>? |
Stored permission keys that define what the key can do. |
OrganizationId |
Guid? |
Optional organization link for administrative tracking and future org-aware use cases. |
ExpiresAt |
DateTime? |
Optional expiration timestamp. |
LastUsedAt |
DateTime? |
Last successful authentication time. |
IsRevoked |
bool |
Permanent revocation flag. |
RevokedAt |
DateTime? |
Time the key was revoked. |
Scopes are now treated as the key’s own permission snapshot.
That means:
- the key does not dynamically inherit future owner role changes
- old integrations remain stable when the owner is promoted or demoted
- leaving scopes empty during creation does not mean unlimited future access
Instead, empty scopes at creation time are converted into a snapshot of the creator’s current effective permissions.
The database configuration is defined in ApplicationDbContext.
Scopesis stored asjsonbCreatedByIdpoints toUserProfilewithDeleteBehavior.RestrictOrganizationIdpoints toOrganizationwithDeleteBehavior.Cascade- unique index on
KeyHash - non-unique index on
Prefix
- duplicate key hashes cannot exist
- the visible prefix is searchable but not guaranteed unique
- deleting an organization deletes keys associated with that organization
- deleting the creator profile is restricted while keys still reference it
This is the shared admin/service DTO for listing and creation.
Validation rules:
| Field | Rules |
|---|---|
Name |
required, min length 2, max length 256 |
The DTO also carries:
PrefixScopesOrganizationIdOrganizationNameExpiresAtLastUsedAtIsRevokedRevokedAtCreatedByNameCreateDate
Key creation returns:
- the normal
ApiKeyItem PlainTextKey
The raw key is only available at creation time. It is not stored and cannot be shown again later.
Key creation happens in ApiKeyAdminService.
The current format is:
dca_<40 lowercase hex characters>
The value is built from:
- the fixed
dca_prefix - 20 random bytes
- lowercase hex encoding
When a key is created:
- the plain-text key is generated once
Prefixis set to the first 8 characters of the generated valueKeyHashis stored as SHA-256 hex- the plain-text key is returned only in the create response
This means the database is usable for verification and admin auditing, but not for recovering the original secret.
API key authentication is implemented by ApiKeyAuthenticationHandler.
Clients authenticate with:
X-Api-Key: dca_...On each request, the handler:
- reads
X-Api-Key - hashes the supplied key with SHA-256
- loads the matching
ApiKeybyKeyHash - rejects the request if the key does not exist
- rejects inactive keys
- rejects revoked keys
- rejects expired keys
- resolves the owner
ApplicationUser - rejects the request if the owner does not exist
- rejects the request if the owner account is not
Active
If validation succeeds, the request becomes authenticated as the owning user identity, with an ApiKeyId claim attached.
The smart auth selector now prefers JWT bearer auth before API key auth.
That matters when a request accidentally includes both:
Authorization: Bearer ...X-Api-Key: ...
In that case, bearer auth wins and the request does not get hijacked by an unrelated or stale API key header.
Authorization still uses the normal permission-policy system.
For API-key-authenticated requests:
PermissionClaimsTransformationdetects theApiKeyIdclaim- it loads the key’s stored
Scopes - it creates
Permissionclaims directly from those stored values - it does not rebuild permissions from the owner’s current roles
This is the key behavioral rule in the current implementation:
API key authorization comes from the key record, not from the owner’s live role membership.
Earlier behavior allowed old keys to silently gain or lose access when the owner’s roles changed. That made integrations unstable and made least-privilege reviews hard to reason about.
The current approach is safer:
- every key has its own explicit permission set
- owner role changes do not broaden existing keys
- keys can still be revoked centrally by revoking the key or deactivating the owner account
When creating a key, the admin UI accepts a comma-separated list of permission keys.
The service:
- trims each value
- removes blanks
- de-duplicates them
- stores the final list in sorted order
The service snapshots the creator’s current effective permissions by:
- loading the creator’s
ApplicationUserId - loading all current role memberships
- resolving role permissions
- applying user permission overrides
- storing the resulting permission list on the key
That means “empty scopes” currently means:
use my current permissions right now as the key's stored permission set
It does not mean:
- unlimited access
- dynamic future access
- bypass permission checks
The starter includes ApiKeyDataSeeder to handle older keys created before scope snapshotting existed.
On startup it:
- finds keys where
Scopesisnullor empty - resolves the owner’s current effective permissions once
- stores that permission list on the key
This makes old keys stable going forward without requiring manual re-creation.
After this change, legacy keys stop drifting with owner role changes. Whatever permission set they receive during backfill becomes their stable scope set unless the key is revoked and recreated.
The current implementation now exposes optional organization association in the admin contract and UI.
Today it primarily provides:
- administrative grouping
- future-proofing for org-aware integrations
- parity with other org-capable features in the starter
The API key’s OrganizationId does not automatically change the request’s operation context or override the owner’s visible organizations.
So today it should be treated as:
- meaningful metadata
- useful for management and future extension
but not as a full tenant-isolation boundary by itself.
If a project needs strict org-bound machine auth, this area should be extended further so request context is derived from the key as well.
The admin page is available at:
/admin/api-keys
- list keys
- search by name or prefix
- view prefix, scope count, organization, creator, expiry, last-used time, and status
- create new keys
- copy the raw key once immediately after creation
- revoke keys
NameScopesOrganization Public IDExpires At
- no edit flow after creation
- no secret reveal after creation
- no rotate action
- no per-key description field
- no key usage audit beyond
LastUsedAt
Revocation is permanent in the current starter behavior.
The admin feature uses these permission keys:
| Permission | Meaning |
|---|---|
Admin.ApiKeys.View |
View the API key page and list keys |
Admin.ApiKeys.Create |
Create new API keys |
Admin.ApiKeys.Revoke |
Revoke existing API keys |
These are admin-management permissions. They are separate from the permissions stored on the keys themselves.
Developers using this starter should know these current constraints:
You can create and revoke keys, but not:
- rotate in place
- rename with history
- edit scopes after creation
- recover a lost key
If an integration needs a changed permission set, the intended workflow is to create a new key and revoke the old one.
LastUsedAt is updated after successful auth in a fire-and-forget path. It is useful operationally, but it should not be treated as a strict audit ledger.
The key can be linked to an organization record, but request context still fundamentally resolves through the owning user.
This is intentional. If the owning account is deactivated, its keys stop working too.
From a client or integration, call the application with:
X-Api-Key: dca_your_generated_key_hereFrom the server side, continue using normal authorization policies. API key requests end up with the same Permission claim shape as other authenticated requests, so existing policy-based checks continue to work.
Examples:
[Authorize(Policy = "Admin.Users.View")][Authorize(Policy = "System.Jobs.View")]
As long as the key’s stored Scopes contain the required permission key, the request can satisfy that policy.
For teams starting from DevCoreApp, the safest operating model is:
- create separate keys per integration
- explicitly supply a narrow scope list when possible
- use expiration dates for temporary or external integrations
- treat empty scopes as a convenience snapshot, not as the default best practice
- revoke and recreate keys instead of trying to mutate them in place
The current API key implementation is a solid starter-level machine authentication feature with hashed storage, permission snapshots, owner-state enforcement, revocation, and basic admin management.
Its main strengths are:
- simple operational model
- compatibility with the existing permission system
- safer stable authorization than the earlier role-drifting design
Its main deliberate limitations are:
- no rotation workflow
- no post-create editing
- no full org-context enforcement from the key itself
- best-effort rather than audit-grade usage tracking
For many internal tools and first-party integrations, that is enough. Projects with more advanced integration security requirements can extend this foundation later.