{"record":{"id":"8cdfe8451a064079","repo":"Budibase/budibase","slug":"link-confirmation-is-invalid-or-has-expired","errorCode":null,"errorMessage":"Link confirmation is invalid or has expired","messagePattern":"Link confirmation is invalid or has expired","errorType":"http","errorClass":"HTTPError","httpStatus":400,"severity":"error","filePath":"packages/server/src/api/controllers/ai/chatIdentityLinks.ts","lineNumber":277,"sourceCode":"    throw new HTTPError(\"Link token is invalid or has expired\", 400)\n  }\n  assertSessionMatchesInstance({\n    workspaceId: session.workspaceId,\n    instance: ctx.params.instance,\n  })\n\n  if (!ctx.isAuthenticated) {\n    throw new HTTPError(\"Authentication is required to link chat identity\", 401)\n  }\n\n  const currentGlobalUserId = getCurrentGlobalUserId(ctx)\n  if (\n    !session.confirmationToken ||\n    !session.confirmationGlobalUserId ||\n    session.confirmationGlobalUserId !== currentGlobalUserId ||\n    ctx.request.body?.confirmationToken !== session.confirmationToken\n  ) {\n    throw new HTTPError(\"Link confirmation is invalid or has expired\", 400)\n  }\n\n  const consumedSession =\n    await sdk.ai.chatIdentityLinks.consumeChatIdentityLinkSession(token)\n  if (!consumedSession) {\n    throw new HTTPError(\"Link token is invalid or has expired\", 400)\n  }\n  assertSessionMatchesInstance({\n    workspaceId: consumedSession.workspaceId,\n    instance: ctx.params.instance,\n  })\n\n  await sdk.ai.chatIdentityLinks.upsertChatIdentityLink({\n    provider: consumedSession.provider,\n    externalUserId: consumedSession.externalUserId,\n    externalUserName: consumedSession.externalUserName,\n    teamId: consumedSession.teamId,\n    providerTenantId: consumedSession.providerTenantId,","sourceCodeStart":259,"sourceCodeEnd":295,"githubUrl":"https://github.com/Budibase/budibase/blob/a81a902e9a8fe55b467d106765f6638f12e35c49/packages/server/src/api/controllers/ai/chatIdentityLinks.ts#L259-L295","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["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.","Verify the browser session posting the confirmation is the same user who rendered the confirmation page (same cookie jar, no account switch).","Check that the confirmation form submits the hidden confirmationToken field in the POST body exactly as rendered.","If links keep expiring quickly, investigate session TTL in chatIdentityLinks SDK (storage/expiration) or clock skew between app servers.","Clear stale link sessions via the SDK (delete/recreate the chat identity link session) and retry."],"exampleFix":"// before (client posting without token)\nawait fetch(`/api/chat-links/${instance}/${token}/handoff`, { method: \"POST\" })\n// after\nconst html = await (await fetch(`/api/chat-links/${instance}/${token}/handoff`)).text()\nconst confirmationToken = html.match(/name=\"confirmationToken\" value=\"([^\"]+)\"/)?.[1]\nawait fetch(`/api/chat-links/${instance}/${token}/handoff`, {\n  method: \"POST\",\n  body: JSON.stringify({ confirmationToken }),\n})","handlingStrategy":"validation","validationCode":"const isValid = (session, body, currentGlobalUserId) =>\n  Boolean(session?.confirmationToken) &&\n  Boolean(session?.confirmationGlobalUserId) &&\n  session.confirmationGlobalUserId === currentGlobalUserId &&\n  body?.confirmationToken === session.confirmationToken\nif (!isValid(session, requestBody, userId)) throw new Error(\"invalid confirmation\")","typeGuard":"const hasConfirmation = (s: { confirmationToken?: string; confirmationGlobalUserId?: string }, body: unknown, userId: string): s is { confirmationToken: string; confirmationGlobalUserId: string } =>\n  typeof s.confirmationToken === \"string\" &&\n  s.confirmationGlobalUserId === userId &&\n  typeof body === \"object\" && body !== null &&\n  (body as { confirmationToken?: unknown }).confirmationToken === s.confirmationToken","tryCatchPattern":"try {\n  await api.post(`/api/chat-links/${instance}/${token}/handoff`, { confirmationToken })\n} catch (e) {\n  if (e.status === 400) await restartHandoffFlow(token) // re-render confirmation page for a fresh token\n}","preventionTips":["Always complete the confirmation in the same browser session and account that rendered the page","Never cache or replay confirmationToken values across flows","Disable double-submit on the confirmation form","Re-fetch the handoff page to regenerate the token if any step fails"],"tags":["http-400","validation","chat-identity-links","session-expired"],"backgroundTag":"link-confirmation-token-invalid","analyzedSha":"a81a902e9a8fe55b467d106765f6638f12e35c49","analyzedAt":"2026-08-29T01:03:10.972Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}