> ## Documentation Index
> Fetch the complete documentation index at: https://cal.com/help/llms.txt
> Use this file to discover all available pages before exploring further.

# Set up automatic secret rotation (Microsoft 365)

> Hand client secret rotation for an existing Microsoft 365 Delegation Credential over to Cal.com, and diagnose it when it stops working.

If you already have a working Microsoft 365 [Delegation Credential](/help/enterprise/delegation-credential-microsoft-365), you can let Cal.com replace its Microsoft Entra client secret before it expires, instead of rotating it by hand every few months. This page covers the setup for a credential that already exists — including credentials created before automatic rotation was available.

<Note>
  Automatic rotation applies to Microsoft 365 delegation credentials only. Google Workspace delegation credentials authenticate with a service account key that doesn't expire, so there's nothing to rotate.
</Note>

***

## What you need

* **Organization admin access** in Cal.com
* **Microsoft Entra admin access** for the tenant that owns the app registration, in a **work or school** tenant (this setup cannot be completed with a personal/consumer Microsoft account)
* The [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli)

***

## Step 1: Grant the rotation permission

Cal.com rotates the secret by calling Microsoft Graph `addPassword` on your app registration, which requires the `Application.ReadWrite.OwnedBy` application permission.

<Steps>
  <Step title="Add the permission">
    In the [Microsoft Entra admin center](https://entra.microsoft.com), open your app registration, go to **API permissions → Add a permission → Microsoft Graph → Application permissions**, and add `Application.ReadWrite.OwnedBy`.
  </Step>

  <Step title="Grant admin consent">
    Click **Grant admin consent for \<your tenant>** and confirm that the permission shows as granted.
  </Step>
</Steps>

***

## Step 2: Make the app an owner of itself

`Application.ReadWrite.OwnedBy` only lets the application manage app registrations it **owns**. Granting the permission is not enough on its own — the application's own service principal has to be an owner of its app registration. This is the most common reason rotation looks configured but keeps failing.

The **Owners** blade in the portal only accepts users, so the ownership has to be added with the Azure CLI.

You need two different object IDs:

| Value                           | Where to find it                                                                                                                    |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| App registration **Object ID**  | **App registrations → your app → Overview → Object ID**. This is *not* the Application (client) ID.                                 |
| Service principal **Object ID** | **Enterprise applications → the same app → Overview → Object ID**. This is a different value from the app registration's Object ID. |

```bash theme={null}
az login --tenant <tenantId> --allow-no-subscriptions

az ad app owner add \
  --id <applicationObjectId> \
  --owner-object-id <servicePrincipalObjectId>

# verify the service principal now shows up as an owner
az ad app owner list --id <applicationObjectId> --query "[].id"
```

***

## Step 3: Check your tenant secret lifetime policy

Cal.com creates each replacement secret with a **180-day** lifetime. If your tenant has an [app management policy](https://learn.microsoft.com/graph/api/resources/tenantappmanagementpolicy) that caps client secret lifetimes below 180 days, Microsoft rejects every attempt and rotation never succeeds.

If that applies to you, either exempt this app registration from the policy, relax the cap to at least 180 days, or keep rotating the secret manually by opting the credential out of automatic rotation.

***

## Step 4: Make sure the credential isn't opted out

Automatic rotation is on by default. If the credential was explicitly opted out, turn that off with [API v2](https://cal.com/docs/api-reference/v2/introduction) — there's no toggle for this in the dashboard yet:

```http theme={null}
PATCH /v2/organizations/{orgId}/delegation-credentials/{credentialId}
Content-Type: application/json

{ "optOutAutoSecretRotation": false }
```

A `GET` on the same endpoint shows the current value.

***

## Step 5: Hand the current secret over to Cal.com

Cal.com can only rotate a secret it created itself — it has no way to learn the expiry date of a secret you pasted in. So a credential that has never had a Cal.com-managed secret needs one staged once, which happens when a credential is enabled:

<Steps>
  <Step title="Disable the credential">
    Go to **Settings → Organization → Delegation Credential** and toggle the credential off.
  </Step>

  <Step title="Enable it again">
    Toggle it back on. Cal.com verifies the credential, then creates a Cal.com-managed secret on the app registration and stages it as **pending** — your own secret stays the active one, so nothing breaks in the meantime.
  </Step>

  <Step title="Wait for promotion">
    Within about an hour, Cal.com validates the pending secret with an app-only token request and promotes it to active. From then on, Cal.com knows the expiry and rotates it automatically about a week before it runs out.
  </Step>
</Steps>

<Note>
  If you'd rather replace the secret at the same time, updating the client secret through API v2 disables the credential as part of the update — enabling it again then stages the managed secret.
</Note>

***

## Step 6: Verify it worked

`GET /v2/organizations/{orgId}/delegation-credentials/{credentialId}` returns the rotation state:

```json theme={null}
{
  "optOutAutoSecretRotation": false,
  "secretRotationBlocked": false,
  "secret": { "expiresAt": "2026-08-19T10:12:00.000Z" },
  "pendingSecret": { "createdAt": null, "expiresAt": null },
  "secretRotationErrorCode": null
}
```

* `secret.expiresAt` filled in and roughly 180 days out means a Cal.com-managed secret is active and rotation is set up.
* `pendingSecret.createdAt` set means a replacement is staged and waiting for validation.
* `secretRotationErrorCode` tells you why the last attempt failed — see below.

In the Microsoft Entra admin center you'll see the old secret still listed under **Certificates & secrets**. That's expected: Cal.com leaves it to expire on its own rather than deleting it, so requests already in flight aren't disrupted.

***

## When rotation fails

Cal.com retries a failing rotation for **48 hours**. After that it stops trying, sets `secretRotationBlocked` to `true` and fires a `DELEGATION_CREDENTIAL_SECRET_ROTATION_FAILED` [webhook](/help/webhooks) whose `error.code` is `SECRET_MINT_FAILED` (Microsoft refused to create the replacement) or `SECRET_PROMOTION_FAILED` (the replacement was created but never authenticated successfully).

| `secretRotationErrorCode`         | What it means                                                                                                | What to do                                                                                                  |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| `missing_permission`              | Microsoft rejected the call as unauthorized — the permission isn't consented, or the app doesn't own itself. | Redo [Step 1](#step-1-grant-the-rotation-permission) and [Step 2](#step-2-make-the-app-an-owner-of-itself). |
| `tenant_lifetime_policy`          | A tenant policy rejects the 180-day secret lifetime.                                                         | See [Step 3](#step-3-check-your-tenant-secret-lifetime-policy).                                             |
| `transient`                       | A temporary Microsoft Graph error (throttling or a 5xx).                                                     | Nothing — Cal.com retries on the next run.                                                                  |
| `pending_secret_mint_failed`      | Creating the replacement secret failed for 48 hours and rotation is now blocked.                             | Fix the underlying cause, then reset rotation.                                                              |
| `pending_secret_promotion_failed` | The replacement secret was created but couldn't be validated for 48 hours.                                   | Check that nothing blocks app-only token requests, then reset rotation.                                     |
| `unknown`                         | A failure that doesn't match any of the above.                                                               | Contact [support](https://cal.com/support) with the credential's domain.                                    |

Treat any value you don't recognize as `unknown` — new codes can be added over time.

Once you've fixed the underlying problem, clear the block from **Settings → Organization → Delegation Credential → ⋯ → Reset rotation** (only selectable while a credential is blocked), or with `POST /v2/organizations/{orgId}/delegation-credentials/{credentialId}/reset-rotation`. Rotation then resumes on the next scheduled run.

<Warning>
  A Conditional Access policy that blocks app-only (`client_credentials`) token requests breaks rotation even when the permissions and ownership are correct: the new secret is created but can never be validated, so it shows up as a promotion failure.
</Warning>

***

## Audit rotations with webhooks

Three [webhook](/help/webhooks) triggers cover the rotation lifecycle:

* `DELEGATION_CREDENTIAL_SECRET_ROTATED` — a managed secret was validated and promoted to active. Useful as an audit trail.
* `DELEGATION_CREDENTIAL_SECRET_ROTATION_FAILED` — rotation reached a terminal failure and the credential is blocked. Subscribe to this if you rely on automatic rotation.
* `DELEGATION_CREDENTIAL_ROTATION_REQUIRED` — sent for credentials that opted **out** of automatic rotation, as a reminder to rotate manually.
