paperclipai/paperclip · error · ToolGatewayHttpError

grant_credential_invalid

grant_credential_invalid

Error message

Personal authorization has an invalid credential

What it means

A connection grant of kind 'user' references a credential secret, but the secret row looked up from companySecrets is either missing, not scoped to 'user', not owned by the grant's subjectUserId, or lacks a userSecretDefinitionId. The tool gateway throws this 422 before resolving the personal credential, because a personal authorization grant may only use a user-scoped secret owned by the subject user and tied to a user secret definition.

Solutions

  1. Re-create the personal authorization so the OAuth callback regenerates the user-scoped secret with the correct ownerUserId and userSecretDefinitionId.
  2. Inspect the companySecrets row for ref.secretId and fix scope ('user'), ownerUserId (must equal grant.subjectUserId), and userSecretDefinitionId.
  3. If the grant should not be personal, change it to a non-user kind (e.g. agent/company) so the generic resolveSecretValue path is used instead.
  4. Verify the user secret definition exists for the connection's credential; re-register the definition and relink the secret.

Example fix

// before: company-scoped secret attached to a personal grant
{ id: 'sec_123', scope: 'company', ownerUserId: null, userSecretDefinitionId: null }
// after: user-scoped secret owned by the grant subject
{ id: 'sec_123', scope: 'user', ownerUserId: 'usr_42', userSecretDefinitionId: 'usd_api_key' }
Defensive patterns

Strategy: validation

Validate before calling

const [secret] = await db.select().from(companySecrets).where(and(eq(companySecrets.id, ref.secretId), eq(companySecrets.companyId, companyId)));
if (!secret || secret.scope !== 'user' || secret.ownerUserId !== grant.subjectUserId || !secret.userSecretDefinitionId) {
  throw new Error(`Grant ${grant.id} references an invalid credential for ${ref.secretId}`);
}

Type guard

function isValidUserSecret(s: typeof companySecrets.$inferSelect | undefined, subjectUserId: string): s is typeof companySecrets.$inferSelect & { scope: 'user'; ownerUserId: string; userSecretDefinitionId: string } {
  return !!s && s.scope === 'user' && s.ownerUserId === subjectUserId && typeof s.userSecretDefinitionId === 'string';
}

Try / catch

try {
  const value = await resolveCredentialHeaders(session, connection, grant);
} catch (e) {
  if (e instanceof ToolGatewayHttpError && e.code === 'grant_credential_invalid') {
    // surface 're-authorize personal connection' to the user
  }
  throw e;
}

Prevention

When it happens

Trigger: resolveGrantSecretValue is called for a user-kind grant whose grant.credentialSecretRefs point to a secret that (a) does not exist in companySecrets for the company, (b) has scope 'company' instead of 'user', (c) has ownerUserId different from grant.subjectUserId, or (d) was created without a userSecretDefinitionId linkage.

Common situations: An operator manually copied a company-level secret into a personal grant's credential refs; a user's OAuth callback created the secret but the definition linkage migration was skipped; secrets were re-created after a company clone so the grant references a stale secret id; a grant was reassigned to a different user without updating the secret owner.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/2e82f8a4d0d6e5b9. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/tool-gateway.ts:3524

        scope: companySecrets.scope,
        ownerUserId: companySecrets.ownerUserId,
        userSecretDefinitionId: companySecrets.userSecretDefinitionId,
      })
      .from(companySecrets)
      .where(
        and(
          eq(companySecrets.id, ref.secretId),
          eq(companySecrets.companyId, connection.companyId),
        ),
      )
      .limit(1);
    if (
      !secret ||
      secret.scope !== "user" ||
      secret.ownerUserId !== grant.subjectUserId ||
      !secret.userSecretDefinitionId
    ) {
      throw new ToolGatewayHttpError(
        422,
        "Personal authorization has an invalid credential",
        "grant_credential_invalid",
        {
          connectionId: connection.id,
          grantId: grant.id,
          credential: configPath,
        },
      );
    }
    const resolved = await secrets.resolveUserSecretValue(
      connection.companyId,
      {
        definitionId: secret.userSecretDefinitionId,
        responsibleUserId: grant.subjectUserId,
        version: ref.versionSelector ?? "latest",
        required: ref.required ?? true,
      },

View on GitHub (pinned to 3f1d897a7c)