calcom/cal.diy · error · UnauthorizedException

CustomThrottlerGuard - Invalid API Key

Error message

CustomThrottlerGuard - Invalid API Key

What it means

Thrown by CustomThrottlerGuard.getRateLimitsForApiKeyTracker when the tracker has an 'api_key_' prefix (meaning the Authorization header started with the API key prefix, default 'cal_') but the SHA-256 hash of the stripped key does not match any record in the apiKey table. The guard recognizes the key format but cannot find it in the database to load custom rate limits. This throws UnauthorizedException (HTTP 401) rather than ThrottlerException because it is an authentication failure, not a rate-limit violation.

Source

Thrown at apps/api/v2/src/lib/throttler-guard.ts:160

    const cacheKey = `rate_limit:${tracker}`;

    const cachedRateLimits = await this.storageService.redis.get(cacheKey);
    if (cachedRateLimits) {
      /*this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
        ${cachedRateLimits}
      `);*/
      return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
    }

    const apiKey = tracker.replace("api_key_", "");
    let rateLimits: RateLimitType[];
    const apiKeyRecord = await this.dbRead.prisma.apiKey.findUnique({
      where: { hashedKey: apiKey },
      select: { id: true },
    });

    if (!apiKeyRecord) {
      throw new UnauthorizedException("CustomThrottlerGuard - Invalid API Key");
    }

    rateLimits = await this.dbRead.prisma.rateLimit.findMany({
      where: { apiKeyId: apiKeyRecord.id },
      select: { name: true, limit: true, ttl: true, blockDuration: true },
    });

    if (!rateLimits || rateLimits.length === 0) {
      rateLimits = [this.getDefaultRateLimit(tracker)];
      /*this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
        ${JSON.stringify(rateLimits, null, 2)}`);*/
    }

    await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);

    return rateLimits;
  }

View on GitHub (pinned to 176037d0af)

Solutions

  1. Verify the API key is complete, correctly formatted, and starts with the expected prefix (check API_KEY_PREFIX env on the server).
  2. Confirm the key still exists in the database and has not been deleted or expired.
  3. If the key was recently refreshed via the refresh endpoint, update the client to use the new key returned in the refresh response.
  4. Check for database replication lag if the key was just created: wait a few seconds and retry.

Example fix

// before: hardcoded key that may be stale
const apiKey = 'cal_stale_key_from_config';

// after: validate key existence and handle 401 gracefully
const callApi = async (apiKey: string) => {
  const res = await fetch('/v2/event-types', {
    headers: { Authorization: `Bearer ${apiKey}` }
  });
  if (res.status === 401) {
    throw new Error('API key invalid or expired. Refresh the key and retry.');
  }
  return res.json();
};
Defensive patterns

Strategy: validation

Validate before calling

// Validate API key format and prefix before sending
const API_KEY_PREFIX = 'cal_'; // match server's API_KEY_PREFIX env
const isValidApiKeyFormat = (key: string): boolean => {
  if (!key || typeof key !== 'string') return false;
  if (!key.startsWith(API_KEY_PREFIX)) return false;
  const stripped = key.slice(API_KEY_PREFIX.length);
  return stripped.length > 10; // minimum reasonable length
};
if (!isValidApiKeyFormat(apiKey)) {
  throw new Error(`Invalid API key format. Expected prefix: ${API_KEY_PREFIX}`);
}

Type guard

const isValidApiKey = (key: unknown): key is string =>
  typeof key === 'string' && key.startsWith('cal_') && key.length > 10;

Try / catch

// Handle 401 from throttler by refreshing the key
try {
  await apiClient.get('/v2/event-types');
} catch (err: any) {
  if (err?.response?.status === 401 && err?.response?.data?.message?.includes('Invalid API Key')) {
    // Key is stale or invalid; generate a new one
    const newKey = await generateNewApiKey();
    apiClient.setHeader('Authorization', `Bearer ${newKey}`);
    // Retry once with the new key
    return apiClient.get('/v2/event-types');
  }
  throw err;
}

Prevention

When it happens

Trigger: A request sends 'Authorization: Bearer cal_<key>' where the key was deleted from the database, expired, or belongs to a different environment. The API_KEY_PREFIX env differs between the client and server (e.g. client uses 'cal_' but server expects a custom prefix), causing stripApiKey to produce the wrong hash. The key was rotated/refreshed (old key is deleted) and the client still uses the stale key.

Common situations: Stale API key after a refresh operation (refreshApiKey deletes the old key at line 76). Mismatched API_KEY_PREFIX between environments (dev vs staging vs production). Key copied with extra whitespace or truncation. Database read replica lag causing a just-created key to be temporarily invisible.

Understand the failure class

Related errors


AI-assisted analysis of calcom/cal.diy@176037d0af (2026-08-12). Data as JSON: /api/errors/879127abfe9c7a99. Report an issue: GitHub.