Skip to content

Key Rotation Integration

This guide covers the developer-facing details of the unified credential model: how CredentialService works under the hood, how to add new credential types, and how credentials propagate to outpost deployments.

For admin operations (rotating, revoking, listing), see Credential Management. For the API reference, see Credentials API.


All credential types — OAuth client secrets, API keys, SCIM tokens, and client identity keys — are stored in a single credentials table. This replaces the previous pattern of separate tables per credential type.

Column Type Description
id UUID Credential identifier
org_id UUID Owning organization
type enum oauth_client, api_key, scim_token, client_identity
owner_id UUID Polymorphic FK — user, client, or service that owns this credential
owner_type enum user, oauth_client, scim_config, client_identity
name string(255) Human-readable label
key_prefix string(8) First 8 chars of the secret (for identification)
current_hash string(64) SHA-256 hex digest of the active secret
previous_hash string(64) Nullable — hash of prior secret during grace period
previous_expires_at timestamp Nullable — when the grace period ends
rotated_at timestamp Nullable — when last rotation occurred
grace_period_seconds int Default 3600 (1 hour), configurable per credential
status enum active, revoked, expired
created_at timestamp Creation time
revoked_at timestamp Nullable — when revoked
created_by UUID Who created or last rotated

The table uses targeted indexes for the two primary access patterns:

Index Columns Purpose
ix_credentials_current_hash current_hash Fast authentication lookup by current secret
ix_credentials_previous_hash previous_hash Fast authentication lookup during grace period
ix_credentials_org_id org_id List credentials by organization
ix_credentials_org_type org_id, type List credentials by org + type filter
ix_credentials_owner owner_id, owner_type Look up credentials by owner entity

CredentialService.authenticate() is the single entry point for all credential authentication. Every middleware that validates secrets calls this method — there are no type-specific authentication paths.

The method hashes the incoming secret with SHA-256 and runs a single query:

SELECT * FROM credentials
WHERE (current_hash = :hash
OR (previous_hash = :hash AND previous_expires_at > now()))
AND status = 'active'

This query checks:

  1. Current secret — direct hash match
  2. Previous secret — hash match AND within the grace window

If both conditions fail, authentication returns None (invalid credential).

The authenticate() method accepts optional filters to narrow the lookup:

Parameter Type Effect
type_filter CredentialType Restrict to a specific credential type
owner_id UUID Restrict to a specific owner entity
org_id UUID Restrict to a specific organization

Use these filters when the calling context knows which credential type to expect. For example, the SCIM middleware passes type_filter=CredentialType.SCIM_TOKEN to avoid matching an API key that happens to share the same hash (cryptographically unlikely but defensively correct).

from backend.app.services.credential_service import CredentialService
from backend.app.models.credential import CredentialType
async def authenticate_request(db, bearer_token: str):
credential = await CredentialService.authenticate(
db,
bearer_token,
type_filter=CredentialType.API_KEY,
)
if credential is None:
raise HTTPException(status_code=401, detail="Invalid credential")
return credential

Adding a new credential type requires two changes:

In backend/app/models/credential.py, add values to both enums:

class CredentialType(str, enum.Enum):
OAUTH_CLIENT = "oauth_client"
API_KEY = "api_key"
SCIM_TOKEN = "scim_token"
CLIENT_IDENTITY = "client_identity"
# Add your new type:
WEBHOOK_SECRET = "webhook_secret"
class CredentialOwnerType(str, enum.Enum):
USER = "user"
OAUTH_CLIENT = "oauth_client"
SCIM_CONFIG = "scim_config"
CLIENT_IDENTITY = "client_identity"
# Add the owner type if new:
WEBHOOK_CONFIG = "webhook_config"

2. Create credentials through CredentialService

Section titled “2. Create credentials through CredentialService”

No model changes needed. Use CredentialService.create() with the new type:

credential, plaintext = await CredentialService.create(
db,
org_id=org.id,
type=CredentialType.WEBHOOK_SECRET,
owner_id=webhook_config.id,
owner_type=CredentialOwnerType.WEBHOOK_CONFIG,
name="production-webhook",
grace_period_seconds=1800, # 30 minutes
created_by=admin.id,
)
# Return `plaintext` to the caller — it is never retrievable after this

The new credential type immediately gets:

  • Zero-downtime rotation via POST /api/v1/admin/credentials/{id}/rotate
  • Grace-period support with the configured duration
  • Revocation via DELETE /api/v1/admin/credentials/{id}
  • Listing and filtering via GET /api/v1/admin/credentials?type=webhook_secret

No new endpoints, middleware, or database migrations required (beyond the Alembic migration for the enum value).


The grace period is set per credential at creation time via grace_period_seconds. It cannot be changed after creation — to adjust the grace period, create a new credential with the desired value.

Value Behavior
3600 (default) Previous secret valid for 1 hour after rotation
0 No grace period — previous secret invalidated immediately on rotation
86400 24-hour grace period for credentials with many distributed consumers
  • API keys for CI/CD: 3600 (1 hour) — sufficient for pipeline secret rotation
  • OAuth client secrets: 3600 (1 hour) — standard for M2M credential updates
  • SCIM tokens: 1800 (30 minutes) — IdP sync is typically fast to reconfigure
  • High-fanout credentials: 86400 (24 hours) — when many distributed services consume the same credential and need time to pick up the new value

All secrets are generated by _generate_secret() in credential_service.py:

  1. Generate 24 cryptographically random bytes → 48 hex characters
  2. Prepend the arx_ prefix → final secret is 52 characters
  3. Compute SHA-256 of the plaintext → stored as current_hash
  4. Extract first 8 characters → stored as key_prefix

The plaintext is returned to the caller and never stored. Only the hash is persisted.


Outpost deployments maintain a local credential cache that syncs from the platform. When a credential is rotated on the platform:

  1. The outpost’s next sync cycle picks up the updated current_hash and previous_hash
  2. During the sync interval, the outpost continues to accept the old credential via its local cache
  3. After sync completes, the outpost recognizes both current and previous secrets (grace period applies)

The platform-side grace period and the outpost sync interval are independent clocks. The grace period counts from the moment of rotation on the platform — not from when the outpost receives the update. Plan accordingly for environments with long sync intervals.


Every credential operation generates an audit event:

Operation Audit fields
Create Credential ID, type, owner, org, created_by
Rotate Credential ID, old key_prefix, new key_prefix, grace duration, triggered_by
Revoke Credential ID, triggered_by, revoked_at

Audit events are written to the platform audit log and are queryable via the audit export API.