
Give integrations their own access keys
A service can use its own scoped, revocable access key instead of a person’s password. Wildo ties the credential to its roles and organization or application, with lifecycle actions for rotation and withdrawal.
A key can be replaced while a controlled overlap gives the integration time to switch.
Example — Rotate a reporting integration’s key
Choose an overlap deadline when rotating, then update the reporting service with the replacement secret before that deadline. The integration keeps the same intended responsibilities while its credential changes.
For engineers
An organization administrator creates the credential through the organization API-key resource. Supply a recognizable name, the roles the integration needs and an optional expiry. The requested roles must fall within the caller’s grant ceiling. The organization-scoped route selects the account; application keys use their separate application resource.
The creation response includes plainKey once, alongside the key record. Save that value in the integration’s credential store before leaving the creation step. For an organization integration, it starts with sk_org_; the application variant uses sk_app_.
Set RESOURCE_URL to an allowed endpoint within that key’s scope and API_KEY to the returned plainKey. This is the request contract declared in api-keys.shared.schemas.ts and exercised by external-access-auth.e2e.ts:
curl "$RESOURCE_URL" \
-H "Authorization: $API_KEY"
The header contains the raw key, without a Bearer prefix. The backend resolves its machine principal, scope and roles before authorizing the requested operation. A successfully authenticated key can still receive an authorization refusal when its role or tenant does not match the operation.
Keep the secret and the authority separate
The API-key create handler checks the requested roles, creates secret material and lets the normal resource path persist the hash. Its response adds the plaintext key once:
This implementation excerpt from api-keys.custom-impl.backend.service.ts shows the decision in context; explanatory source comments are omitted.
export function buildApiKeyMintHandlers(scope: ApiKeyScope, roleHierarchyResolver: RoleHierarchyResolver): ApiKeyImplHandlers {
return {
prefixCoreOperations: async (_id, input, executionContext, _operationPath, utils) => {
assertRequestedRolesWithinCallerCeiling(executionContext, (input as { roles?: string[] }).roles, utils.errorBuilder, roleHierarchyResolver);
const { plainKey, keyPrefix, hashedKey } = generateApiKeyMaterial(scope);
PLAINTEXT_KEY_BY_EC.set(executionContext, plainKey);
return { ...(input as Record<string, unknown>), keyPrefix, hashedKey };
},
postfixCoreOperations: async (_id, createdKey, executionContext, _operationPath, _utils) => {
if (!createdKey || typeof createdKey !== 'object') return createdKey;
const plainKey = PLAINTEXT_KEY_BY_EC.get(executionContext);
PLAINTEXT_KEY_BY_EC.delete(executionContext);
if (!plainKey) return createdKey;
return { ...(createdKey as Record<string, unknown>), plainKey };
},
};
}
Store the one-time result in the integration
Capture plainKey from the creation response and place it in the integration’s credential store. Subsequent resource reads do not recover the plaintext value. The authenticated request resolves the key’s organization or application and the roles assigned at creation.
| Action | Effect |
|---|---|
| Rotate | New key material with a bounded prior-key overlap |
| Regenerate | New material with an open-ended prior-key overlap |
| Extend expiry | Changes the existing credential’s expiry |
| Deactivate or reactivate | Changes whether the credential is usable |
Choose rotation when you need the old secret to stop working at a known time. Regeneration is not the same retirement policy.
Rotate with fresh proof and an explicit cutoff
For the default organization-key ROTATE operation, the caller needs ORG_ADMIN and a single-use reauthentication proof. A recently established session alone does not satisfy the operation’s explicit step-up requirement. Application-level administration uses its separately authorized variant; do not substitute that route for an organization administrator’s call.
This illustrative JavaScript runs in a trusted first-party administration client. reauthUrl is the application’s /auth/reauth API endpoint; rotateUrl is the selected key’s default ROTATE URL from its generated operation contract. sessionToken belongs to the administrator, not to the integration whose key is being replaced. This example uses a locally enrolled password accepted by the effective step-up policy; the standard interface handles other supported factors.
async function rotateIntegrationKey({ reauthUrl, rotateUrl, sessionToken, password, cutoff }) {
const json = async (response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const body = await response.json();
return body.data ?? body;
};
const headers = { authorization: `Bearer ${sessionToken}`, 'content-type': 'application/json' };
const proof = await json(await fetch(reauthUrl, {
method: 'POST', headers,
body: JSON.stringify({ method: 'PASSWORD', password }),
}));
if (!proof.reAuthToken) throw new Error('Reauthentication returned no operation proof');
const replacement = await json(await fetch(rotateUrl, {
method: 'PUT',
headers: { ...headers, 'x-reauth-token': proof.reAuthToken },
body: JSON.stringify({ oldKeyInvalidationDate: cutoff.toISOString() }),
}));
if (!replacement.plainKey) {
throw new Error('Rotation returned no replacement secret');
}
// Hand this directly to the integration's credential store, not a log.
return {
plainKey: replacement.plainKey,
oldKeyId: replacement.oldKeyId,
oldKeyInvalidationDate: replacement.oldKeyInvalidationDate,
};
}
Choose a future cutoff that leaves time to distribute and verify the replacement. Omitting oldKeyInvalidationDate defaults to immediate retirement, not a grace period. Save the returned plainKey once, switch the integration, verify a real permitted request with the new raw key, then verify the prior value is refused after the returned cutoff. Subsequent reads cannot recover the secret.
The proof is short-lived and single-use. A retry may require a new proof; if the rotation response was lost, investigate the key state before blindly rotating again. REGENERATE deliberately has different semantics: it keeps the prior secret without a scheduled cutoff and does not carry the same explicit rotation step-up gate. Neither operation grants new roles.
Keep role changes out of secret maintenance
These resources do not expose an ordinary roles-changing update. Their lifecycle handlers accept their own inputs and do not use rotation as a second path to grant authority. If the integration needs different responsibilities, provision the appropriate credential rather than treating a secret replacement as a permissions change.