{"record":{"id":"19e55db6b1e17373","repo":"hcengineering/platform","slug":"workspace-mismatch-19e55d","errorCode":null,"errorMessage":"Workspace mismatch","messagePattern":"Workspace mismatch","errorType":"http","errorClass":null,"httpStatus":401,"severity":"error","filePath":"services/payment/pod-payment/src/middleware.ts","lineNumber":80,"sourceCode":"    res.status(403).json({ message: 'Missing auth info' }).end()\n    return\n  }\n\n  req.loginInfo = loginInfo\n  next()\n}\n\nexport const withOwner = (req: RequestWithAuth, res: Response, next: NextFunction): void => {\n  void withOwnerAsync(req, res, next)\n}\n\nconst withOwnerAsync = async (req: RequestWithAuth, res: Response, next: NextFunction): Promise<void> => {\n  if (req.token === undefined || req.token == null) {\n    res.status(401).json({ message: 'Token error' }).end()\n    return\n  }\n  if (req.params.workspace != null && req.token.workspace !== req.params.workspace) {\n    res.status(401).json({ message: 'Workspace mismatch' }).end()\n    return\n  }\n  if (req.token.account !== systemAccountUuid && req.token.extra?.admin !== 'true') {\n    const accountClient = getAccountClient(req.headers.authorization?.split(' ')[1])\n    const loginInfo = req.loginInfo ?? (await accountClient.getLoginInfoByToken())\n    if (loginInfo == null) {\n      res.status(403).json({ message: 'Missing auth info' }).end()\n      return\n    }\n    if (!('role' in loginInfo)) {\n      res.status(401).json({ message: 'Missing workspace role' }).end()\n      return\n    }\n    if (loginInfo.role !== AccountRole.Owner) {\n      res.status(401).json({ message: 'Workspace owners only' }).end()\n      return\n    }\n  }","sourceCodeStart":62,"sourceCodeEnd":98,"githubUrl":"https://github.com/hcengineering/platform/blob/63e28dc96483967b2fc21c881b3f1023c1de7718/services/payment/pod-payment/src/middleware.ts#L62-L98","documentation":"withOwnerAsync rejects with 401 'Workspace mismatch' when the route defines req.params.workspace and the token's workspace claim (req.token.workspace) does not equal it. The token is valid but was issued for a different workspace than the one the request targets.","triggerScenarios":"Calling /ws/:workspace/... with a :workspace path param that differs from the workspace encoded in the bearer token — e.g. client app switched workspaces but kept the old token, or a hardcoded workspace id in the URL.","commonSituations":"Frontend switching active workspace without refreshing the token; copying an API URL from another workspace/user; cached tokens stored per-user rather than per-workspace; workspace renamed/re-created so old token claims no longer match.","solutions":["Use the workspace id that matches the token's workspace claim, or re-authenticate to get a token for the target workspace","In the client, refresh the token whenever the active workspace changes","Check for stale/hardcoded workspace ids in API calls and fix the URL construction","Verify token issuance includes the correct workspace claim for the session"],"exampleFix":"// before\nawait api.get(`/ws/${oldWorkspaceId}/invoices`, { headers: { Authorization: `Bearer ${oldWsToken}` } })\n// after\nawait api.get(`/ws/${currentWorkspaceId}/invoices`, { headers: { Authorization: `Bearer ${currentWorkspaceToken}` } })","handlingStrategy":"validation","validationCode":"// decode token workspace claim client-side and compare to target workspace\nconst claims = JSON.parse(atob(token.split('.')[1]))\nif (claims.workspace !== targetWorkspaceId) {\n  await switchWorkspaceToken(targetWorkspaceId) // refresh token scoped to target workspace\n}","typeGuard":"function tokenMatchesWorkspace(token: { workspace?: string } | null, workspaceId: string): token is { workspace: string } {\n  return token != null && typeof token.workspace === 'string' && token.workspace === workspaceId\n}","tryCatchPattern":"try {\n  const res = await callWorkspaceApi(workspaceId)\n} catch (err) {\n  if (err.response?.status === 401 && err.response.data?.message === 'Workspace mismatch') {\n    await refreshTokenForWorkspace(workspaceId)\n    // retry once; otherwise reset client to the token's workspace\n  }\n}","preventionTips":["Refresh the token whenever the user switches active workspace in the UI","Never hardcode workspace ids in API URLs; derive them from current app state","Store tokens keyed by workspace, not per user only","On 401 Workspace mismatch, surface 'switch workspace' UI instead of retrying"],"tags":["auth","http-401","workspace-scoping","token-claim-mismatch"],"backgroundTag":"token-workspace-mismatch","analyzedSha":"63e28dc96483967b2fc21c881b3f1023c1de7718","analyzedAt":"2026-08-29T15:21:27.377Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}