immich-app/immich · error · BadRequestException

Cannot rotate an API Key with permissions you do not have

Error message

Cannot rotate an API Key with permissions you do not have

What it means

Immich's ApiKeyService.rotate (POST /api-keys/:id/rotate) refuses to rotate an API key when the request itself is authenticated with an API key whose permissions do not fully cover the key being rotated. It uses isGranted (server/src/utils/access.ts:13), which checks that the target key's permissions are a subset of the calling credentials (Permission.All acts as a wildcard). This prevents a narrowly-scoped key from regenerating — and thereby learning the plaintext secret of — a more privileged key. It surfaces as a NestJS BadRequestException (HTTP 400).

Source

Thrown at server/src/services/api-key.service.ts:57

      dto.permissions &&
      !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })
    ) {
      throw new BadRequestException('Cannot grant permissions you do not have');
    }

    const key = await this.apiKeyRepository.update(auth.user.id, id, { name: dto.name, permissions: dto.permissions });

    return this.map(key);
  }

  async rotate(auth: AuthDto, id: string): Promise<ApiKeyCreateResponseDto> {
    const existing = await findOrFail(() => this.apiKeyRepository.getById(auth.user.id, id), 'API Key not found');

    if (
      auth.apiKey &&
      !isGranted({ requested: existing.permissions as Permission[], current: auth.apiKey.permissions })
    ) {
      throw new BadRequestException('Cannot rotate an API Key with permissions you do not have');
    }

    const token = this.cryptoRepository.randomBytesAsText(32);
    const hashed = this.cryptoRepository.hashSha256(token);
    const newKey = await this.apiKeyRepository.update(auth.user.id, id, { key: hashed });
    const apiKey = this.map(newKey);

    return { ...apiKey, secret: token, apiKey };
  }

  async delete(auth: AuthDto, id: string): Promise<void> {
    const exists = await this.apiKeyRepository.getById(auth.user.id, id);
    if (!exists) {
      throw new BadRequestException('API Key not found');
    }

    await this.apiKeyRepository.delete(auth.user.id, id);
  }

View on GitHub (pinned to 37e033a09d)

Solutions

  1. Authenticate the rotate call with the user's web session (cookie / OAuth access token) instead of an API key — the permission check only applies when the caller is an API key.
  2. Or use a calling key whose permission set is a superset of the target key's — simplest is a key created with all permissions (Permission.All).
  3. Or first update the calling key's permissions (or delete and recreate it) so it includes every permission the target key holds, then retry the rotation.
  4. In scripts, pre-flight the check: fetch the target key, compare permission arrays, and skip or escalate before calling rotate.

Example fix

// before — scoped key trying to rotate a broader key
await fetch(`${IMMICH_URL}/api-keys/${id}/rotate`, {
  method: 'POST',
  headers: { 'x-api-key': SCOPED_READ_ONLY_KEY },
});
// => 400 { message: 'Cannot rotate an API Key with permissions you do not have' }

// after — rotate with the user's session or an all-permission key
await fetch(`${IMMICH_URL}/api-keys/${id}/rotate`, {
  method: 'POST',
  credentials: 'include', // session cookie; or x-api-key from a key granted all permissions
});
Defensive patterns

Strategy: validation

Validate before calling

// Before rotating: fetch the target key and compare permission sets client-side.
const target = await api.getApiKey(id); // GET /api-keys/{id}
const callingPermissions = parseCallingKeyPermissions(); // permissions of the key making this request

const canRotate =
  callingPermissions.includes('all') ||
  target.permissions.every((p) => callingPermissions.includes(p));

if (!canRotate) {
  throw new Error('Rotate from a session or from a key granted every permission the target key has');
}
await api.rotateApiKey(id); // POST /api-keys/{id}/rotate

Type guard

const isPermissionArray = (value: unknown): value is Permission[] =>
  Array.isArray(value) && value.every((p) => typeof p === 'string');

Try / catch

try {
  return await api.rotateApiKey(id);
} catch (error) {
  if (isHttpError(error, 400, 'Cannot rotate an API Key with permissions you do not have')) {
    // credential mismatch, not a transient failure: re-authenticate (session / all-permission key) instead of retrying
    throw new RotationTokenEscalationError('Re-run rotation with a session cookie or an unrestricted key');
  }
  throw error;
}

Prevention

When it happens

Trigger: Calling POST /api-keys/{id}/rotate with an x-api-key header whose key (a) is not Permission.All and (b) lacks at least one permission that the target key has. Example: a key with only asset.read tries to rotate the admin's full-permission key. Requests authenticated with the user's session (cookie/OAuth) never hit this check because auth.apiKey is undefined in that case.

Common situations: Automation/CI scripts that use a scoped API key but try to rotate every key in the account, including the owner's unrestricted one. Rotating a key using an older key that was created with a narrower permission set before permissions were introduced. Key-management runbooks that assume API keys can manage all keys in the account.

Understand the failure class

Background: "You do not have permission" / 403 Forbidden errors: authenticated but not allowed — causes and fixes across open-source libraries — this error's family across 31 libraries.

Related errors


AI-assisted analysis of immich-app/immich@37e033a09d (2026-08-21). Data as JSON: /api/errors/29550e8d5ef2b2c8. Report an issue: GitHub.