Budibase/budibase · error · HTTPError

Link confirmation is invalid or has expired

Error message

Link confirmation is invalid or has expired

What it means

confirmChatLinkSession validates the chat identity link confirmation step. The server stored a confirmationToken and confirmationGlobalUserId on the session during the handoff stage; this check requires the session to still hold both, that the stored user matches the currently authenticated global user, and that the confirmationToken in the request body matches the stored one. Any mismatch means the confirmation cannot be trusted, so the endpoint refuses with a 400.

Source

Thrown at packages/server/src/api/controllers/ai/chatIdentityLinks.ts:277

    throw new HTTPError("Link token is invalid or has expired", 400)
  }
  assertSessionMatchesInstance({
    workspaceId: session.workspaceId,
    instance: ctx.params.instance,
  })

  if (!ctx.isAuthenticated) {
    throw new HTTPError("Authentication is required to link chat identity", 401)
  }

  const currentGlobalUserId = getCurrentGlobalUserId(ctx)
  if (
    !session.confirmationToken ||
    !session.confirmationGlobalUserId ||
    session.confirmationGlobalUserId !== currentGlobalUserId ||
    ctx.request.body?.confirmationToken !== session.confirmationToken
  ) {
    throw new HTTPError("Link confirmation is invalid or has expired", 400)
  }

  const consumedSession =
    await sdk.ai.chatIdentityLinks.consumeChatIdentityLinkSession(token)
  if (!consumedSession) {
    throw new HTTPError("Link token is invalid or has expired", 400)
  }
  assertSessionMatchesInstance({
    workspaceId: consumedSession.workspaceId,
    instance: ctx.params.instance,
  })

  await sdk.ai.chatIdentityLinks.upsertChatIdentityLink({
    provider: consumedSession.provider,
    externalUserId: consumedSession.externalUserId,
    externalUserName: consumedSession.externalUserName,
    teamId: consumedSession.teamId,
    providerTenantId: consumedSession.providerTenantId,

View on GitHub (pinned to a81a902e9a)

Solutions

  1. Restart the flow: navigate to the handoff URL again while logged in as the intended user so a fresh confirmationToken is generated and rendered into the confirmation page.
  2. Verify the browser session posting the confirmation is the same user who rendered the confirmation page (same cookie jar, no account switch).
  3. Check that the confirmation form submits the hidden confirmationToken field in the POST body exactly as rendered.
  4. If links keep expiring quickly, investigate session TTL in chatIdentityLinks SDK (storage/expiration) or clock skew between app servers.
  5. Clear stale link sessions via the SDK (delete/recreate the chat identity link session) and retry.

Example fix

// before (client posting without token)
await fetch(`/api/chat-links/${instance}/${token}/handoff`, { method: "POST" })
// after
const html = await (await fetch(`/api/chat-links/${instance}/${token}/handoff`)).text()
const confirmationToken = html.match(/name="confirmationToken" value="([^"]+)"/)?.[1]
await fetch(`/api/chat-links/${instance}/${token}/handoff`, {
  method: "POST",
  body: JSON.stringify({ confirmationToken }),
})
Defensive patterns

Strategy: validation

Validate before calling

const isValid = (session, body, currentGlobalUserId) =>
  Boolean(session?.confirmationToken) &&
  Boolean(session?.confirmationGlobalUserId) &&
  session.confirmationGlobalUserId === currentGlobalUserId &&
  body?.confirmationToken === session.confirmationToken
if (!isValid(session, requestBody, userId)) throw new Error("invalid confirmation")

Type guard

const hasConfirmation = (s: { confirmationToken?: string; confirmationGlobalUserId?: string }, body: unknown, userId: string): s is { confirmationToken: string; confirmationGlobalUserId: string } =>
  typeof s.confirmationToken === "string" &&
  s.confirmationGlobalUserId === userId &&
  typeof body === "object" && body !== null &&
  (body as { confirmationToken?: unknown }).confirmationToken === s.confirmationToken

Try / catch

try {
  await api.post(`/api/chat-links/${instance}/${token}/handoff`, { confirmationToken })
} catch (e) {
  if (e.status === 400) await restartHandoffFlow(token) // re-render confirmation page for a fresh token
}

Prevention

When it happens

Trigger: POST to /api/chat-links/:instance/:token/handoff (confirm) where the body omits confirmationToken, supplies a wrong or stale token, the session was never prepared via prepareChatIdentityLinkSessionConfirmation, or the session was prepared for a different logged-in user than the one confirming.

Common situations: A different Budibase user completes the confirmation page than the one who started the handoff; the confirmation page is loaded twice and an old CSRF-style token is replayed after re-preparation; the link expires between rendering the confirmation HTML and submitting; a client script posts without the hidden confirmationToken input.

Related errors


AI-assisted analysis of Budibase/budibase@a81a902e9a (2026-08-29). Data as JSON: /api/errors/8cdfe8451a064079. Report an issue: GitHub.