Skip to content

API Keys — Service-to-Service Authentication

JWT bearer tokens (Keycloak, Entra ID, Cognito, OpenIddict) are the right primitive for interactive users and short-lived service identities. They are the wrong primitive for long-running, non-interactive callers — cron jobs, partner integrations, internal CLIs, lab equipment uploading results overnight. Forcing those callers through a full OIDC client-credentials flow buys little security and adds operational drag (token caches, refresh logic, IdP availability on the critical path).

Granit.Authentication.ApiKeys is the dedicated answer for that audience:

  • Long-lived but rotatable — opaque secrets with optional ExpiresAt, rotation endpoint, and a daily scanner that emails administrators ahead of expiry so a silent expiration never takes a partner integration down at 03:00.
  • Hashed at rest, exposed once — only the SHA-256 digest is persisted; the raw secret is returned exactly once, on the create / rotate response. The hash never leaves the persistence layer.
  • Defence in depth on every emission — integration events, notification payloads, and email templates all carry public-safe metadata only (id, name, type, expiry). The hash, the raw secret, and even the on-the-wire prefix are excluded from anything routed to a recipient.
  • Tenant- and CIDR-scoped — keys are IMultiTenant aggregates and accept an allow-list of CIDR ranges, so a leaked key still cannot be used from outside the configured network.
  • Stripe-style typed prefixesgk_live_sk_… for secret server-to-server, gk_live_pk_… for client-side publishable, gk_live_wh_… for inbound webhooks, gk_live_ep_… for ephemeral workflow keys. Operators recognise the category at a glance without dereferencing the key.
  • DirectoryGranit.Authentication.ApiKeys/ Abstractions, domain, options, generator
    • Granit.Authentication.ApiKeys.EntityFrameworkCore Isolated DbContext + EF configurations
    • DirectoryGranit.Authentication.ApiKeys.Endpoints/ Admin REST API, permissions, validators
    • DirectoryGranit.Authentication.ApiKeys.BackgroundJobs/ Daily expiration scanner
    • DirectoryGranit.Authentication.ApiKeys.Notifications/ Email + InApp notification bridge
PackageRoleDepends on
Granit.Authentication.ApiKeysApiKeyEntry aggregate, IApiKeyStore / IApiKeyAdminStore / IApiKeyGenerator, ApiKeyAuthenticationHandler, CIDR validatorGranit.Users, Granit.Guids, Granit.Timing
Granit.Authentication.ApiKeys.EntityFrameworkCoreIsolated DbContext, entity configurations, migrationsGranit.Persistence
Granit.Authentication.ApiKeys.EndpointsFive admin route groups, ApiKeyPermissions, FluentValidation validatorsGranit.Authorization, Granit.Validation
Granit.Authentication.ApiKeys.BackgroundJobsExpiringApiKeyScannerJob (daily, 07:00 UTC)Granit.BackgroundJobs
Granit.Authentication.ApiKeys.NotificationsFour *NotificationType + Wolverine handlers + EN/FR templatesGranit.Notifications.Abstractions, Granit.Templating

ApiKeyEntry is a FullAuditedAggregateRoot and IMultiTenant. The on-the-wire secret is composed of three parts:

gk_live_sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0
└──┬──┘└┬┘└┬┘└──────────────────┬──────────────────┘
│ │ │ │
│ │ │ └─ random body (last 4 chars stored)
│ │ └─ key kind (sk/pk/wh/ep — see ApiKeyType)
│ └─ environment (live, test, dev — configurable)
└─ Granit prefix

Persisted columns (ApiKeyEntry):

ColumnNotes
IdGuid aggregate id
NameOperator-facing display name (e.g. Partner Lab X)
TypeApiKeyTypeSecret, Publishable, Webhook, Ephemeral
Environmentlive / test / dev (validated against ApiKeysEndpointsOptions.AllowedEnvironments)
HashedKeySHA-256 hex digest. SensitiveData(Restricted, Omit) in audit serialisers.
Prefix, LastFourCharsUI identification only — never the raw secret
PermissionsGranted permission strings (e.g. ["MyApp.Patients.Read"])
AllowedCidrsIP allow-list. Empty = no IP restriction.
ExpiresAtOptional. null means never expires.
LastUsedAtStamped on every successful authentication
RevokedAtnull while active
LastExpirationNotifiedAtUsed by the scanner to dedupe alerts (one per week)
CacheBehaviorPer-key cache override

The raw secret is never persisted: the create / rotate endpoint hashes it, stores the digest, and returns the plain value once in the HTTP response.

stateDiagram-v2
    [*] --> Active: Create()<br/>ApiKeyCreatedEto
    Active --> Active: Used()<br/>ApiKeyUsedEto<br/>(LastUsedAt updated)
    Active --> Active: UpdatePermissions()<br/>ApiKeyScopesUpdatedEto
    Active --> Active: ExpiringSoon<br/>scanner emits<br/>ApiKeyExpiringSoonEto
    Active --> Revoked: Revoke()<br/>ApiKeyRevokedEto
    Active --> Rotated: Rotate()<br/>ApiKeyRotatedEto<br/>(new key created)
    Active --> Expired: ExpiresAt &lt; now<br/>ApiKeyExpiredEvent
    Revoked --> [*]
    Rotated --> [*]
    Expired --> [*]

Five lifecycle events (*Eto reach the Wolverine outbox; *Event is local-only):

EventTypeEmitted byUsed by
ApiKeyCreatedEtoIntegrationApiKeyEntry.Create()Notifications (apikeys.new_key_issued)
ApiKeyUsedEtoIntegrationAuth handler on every accepted callAudit / metering
ApiKeyScopesUpdatedEtoIntegrationUpdatePermissions()Cache invalidation
ApiKeyRotatedEtoIntegrationRotate endpointNotifications (apikeys.rotation_completed)
ApiKeyRevokedEtoIntegrationRevoke()Notifications (apikeys.revoked), cross-instance cache eviction
ApiKeyExpiringSoonEtoIntegrationDaily scannerNotifications (apikeys.expiring_soon)
ApiKeyExpiredEventDomainAuth handler on first rejectionAudit log only — never recipient-bound
{
"Granit": {
"ApiKeys": {
"ExpirationLeadTimeDays": 14
}
}
}
PropertySectionDefaultDescription
ExpirationLeadTimeDaysGranit:ApiKeys14Days before ExpiresAt at which the scanner starts emitting ApiKeyExpiringSoonEto. Range: 1–90.
RoutePrefixApiKeysEndpoints"authentication"URL prefix for the admin endpoints (full path: /{prefix}/api-keys).
TagNameApiKeysEndpoints"API Keys"OpenAPI tag (Title Case With Spaces — see tag conventions).
AllowedEnvironmentsApiKeysEndpointsOptions["live", "test", "dev"]Whitelist enforced at create-time.

ApiKeysOptions lives in the base module and is distinct from the ASP.NET authentication scheme options (ApiKeyOptions) — the former tunes shared background behaviour (scanner cadence, lead time), the latter the JWT-Bearer-style scheme registration on the request pipeline.

All endpoints are mounted under MapGranitApiKeys() (default prefix /authentication/api-keys). Each endpoint declares the five mandatory OpenAPI metadata elements and is gated by a granular permission (see next section).

VerbPathOperationPermission
GET/api-keysListApiKeysAuthenticationApiKeys.Keys.Read
GET/api-keys/{id:guid}GetApiKeyByIdAuthenticationApiKeys.Keys.Read
POST/api-keysCreateApiKeyAuthenticationApiKeys.Keys.Create
POST/api-keys/{id:guid}/revokeRevokeApiKeyAuthenticationApiKeys.Keys.Revoke
POST/api-keys/{id:guid}/rotateRotateApiKeyAuthenticationApiKeys.Keys.Rotate
PUT/api-keys/{id:guid}/scopesUpdateApiKeyScopesAuthenticationApiKeys.Keys.UpdateScopes

Privilege-escalation guard (OWASP API5:2023)

Section titled “Privilege-escalation guard (OWASP API5:2023)”

CreateApiKey and UpdateApiKeyScopes enforce a strict rule: the caller must itself possess every permission being assigned to the key. Trying to grant MyApp.Patients.Delete on a key while the caller does not hold that permission returns 403 Forbidden with an explanatory application/problem+json. This prevents an account with Keys.Create from minting a super-admin key.

CreateApiKey and RotateApiKey are the only two endpoints that return the raw secret, exactly once, in the response body (rawSecret field). After that, the secret cannot be retrieved — GetApiKeyById and ListApiKeys return only prefix, lastFourChars, and the metadata. Operators must capture the value at issuance and store it securely.

All five permissions live under the AuthenticationApiKeys group. Localisation keys: PermissionGroup:AuthenticationApiKeys and Permission:AuthenticationApiKeys.Keys.{Action} (resolved through GET /api/{version}/localization).

PermissionActionLocalisation key
AuthenticationApiKeys.Keys.ReadList + view individual keysPermission:AuthenticationApiKeys.Keys.Read
AuthenticationApiKeys.Keys.CreateIssue new keysPermission:AuthenticationApiKeys.Keys.Create
AuthenticationApiKeys.Keys.RevokeRevoke a keyPermission:AuthenticationApiKeys.Keys.Revoke
AuthenticationApiKeys.Keys.RotateRotate (revoke + reissue with same settings)Permission:AuthenticationApiKeys.Keys.Rotate
AuthenticationApiKeys.Keys.UpdateScopesEdit permissions and CIDR allow-listPermission:AuthenticationApiKeys.Keys.UpdateScopes

Revoke, Rotate, and UpdateScopes are deliberately split (rather than rolled into a single Manage) to support least-privilege delegation under ISO 27001 A.9.4 — for example, granting “rotate only” to an automated rotation agent without giving it the ability to revoke keys outright.

Granit.Authentication.ApiKeys.Notifications ships four notification types, each backed by EN + FR Scriban templates embedded in the assembly. Tenants opt in via the notifications admin UI; channels and severity are defaults that the host can override.

NotificationTrigger eventChannelsSeverity
apikeys.new_key_issuedApiKeyCreatedEto — a new API key was issued (audit trail; the plain secret is delivered via the issuance endpoint, never the email)Email, InAppInfo
apikeys.rotation_completedApiKeyRotatedEto — a key was rotated (overlap rotation, mirroring the webhook signing-key model)Email, InAppInfo
apikeys.revokedApiKeyRevokedEto — a key was explicitly revoked by an operatorEmail, InAppInfo
apikeys.expiring_soonApiKeyExpiringSoonEto — daily scanner; key is within ExpirationLeadTimeDays of ExpiresAt and not notified within the last weekEmail, InAppWarning

Notification data records carry public-safe fields only — KeyId, KeyName, KeyType, ExpiresAt, DaysUntilExpiry. The SHA-256 hash, the raw secret, and the on-the-wire prefix are never bound into a template context. This is enforced at the data-record boundary: there is no path from ApiKeyEntry.HashedKey to a recipient, by construction.

Hosts that want to override the legal wording for a specific tenant override the embedded templates at runtime via the Granit.Templating admin API — the DB-backed resolver runs at higher priority than the embedded one.

Granit.Authentication.ApiKeys.BackgroundJobs ships a single recurring job:

JobSchedule (cron, UTC)Purpose
apikeys-expiring-soon (ExpiringApiKeyScannerJob)0 7 * * * (daily 07:00)Emits ApiKeyExpiringSoonEto for every active key within ExpirationLeadTimeDays of ExpiresAt, dedup’d against LastExpirationNotifiedAt (one alert per key per week).

The job runs early enough to land in administrators’ morning inboxes and late enough to avoid contention with overnight maintenance windows. The handler delegates to ExpiringApiKeyScannerService, which uses IApiKeyAdminStore.ListExpiringSoonAsync to fetch only the keys needing an alert, calls MarkExpirationNotified, and saves the entry — the Eto reaches the Wolverine outbox in the same transaction as the LastExpirationNotifiedAt stamp.

[DependsOn(
typeof(GranitAuthenticationApiKeysModule),
typeof(GranitAuthenticationApiKeysEntityFrameworkCoreModule),
typeof(GranitAuthenticationApiKeysEndpointsModule),
typeof(GranitApiKeysBackgroundJobsModule), // optional — daily scanner
typeof(GranitApiKeysNotificationsModule))] // optional — email + in-app
public sealed class AppModule : GranitModule { }
// In your endpoint registration
app.MapGranitApiKeys();
// or, with overrides:
app.MapGranitApiKeys(options =>
{
options.RoutePrefix = "v1/auth";
options.TagName = "API Keys";
options.AllowedEnvironments = ["prod", "staging"];
});

The .BackgroundJobs and .Notifications packages are independent: the core module functions without either, but you almost always want the scanner + notifications pair to surface expiring keys before they cause an outage.

Additional hardening:

  • Constant-time hash comparisonIApiKeyStore.FindByHashAsync looks up by the precomputed SHA-256 digest; no application-level string comparison of the raw secret happens at request time.
  • CIDR allow-listingAllowedCidrs is enforced before any permission check (a leaked key from outside the configured network is rejected at L3).
  • Tenant isolationApiKeyEntry is IMultiTenant. The framework’s Granit.Persistence query filter applies automatically; no cross-tenant lookup by hash is possible.
  • Privilege-escalation guard — Create / UpdateScopes refuse to assign a permission the caller does not itself hold (OWASP API5:2023).