# Feishu multi-app identity and event-routing primary sources

This note answers one narrow design question for the production SaaS audit:
what identity and routing boundaries apply when **every Hub Organization uses
its own Feishu App and bot**. External claims below use only first-party Feishu
Open Platform documentation. Statements labelled **Derived requirement** are
Hub design implications, not guarantees made by Feishu.

Research date: 2026-07-10.

## Executive answer

1. A customer-owned enterprise custom app is a single-tenant app: Feishu says
   it is developed, reviewed, published, and used inside one tenant and cannot
   be used by another organization. That fits “one Organization, one Feishu
   App,” but it means the Hub is operating many independent app security and
   identity domains, not one shared Feishu identity domain.
   ([custom apps versus store apps](https://open.feishu.cn/document/home/app-types-introduction/self-built-apps-and-store-apps))
2. A bare `open_id` is **not** a SaaS-global user key. It identifies a user
   inside one app, and the same user has different Open IDs in different apps.
   `user_id` is tenant-scoped; it is stable across apps in one tenant but
   differs across tenants. `union_id` crosses apps only for apps from the same
   application provider/developer; it differs between providers.
   ([user resource overview](https://open.feishu.cn/document/server-docs/contact-v3/user/field-overview?lang=zh-CN),
   [official ID parameter definitions](https://open.feishu.cn/document/server-docs/contact-v3/department/search?lang=zh-CN),
   [Union ID guide](https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-obtain-union-id))
3. OAuth must be routed to the Organization's app **before** authorization.
   The authorization request contains that app's App ID as `client_id`; the
   callback contains `code` and the echoed `state`, not an independently
   trustworthy Organization choice. Exchanging the code requires the matching
   app's `client_id` and `client_secret`, and the same `redirect_uri`.
   ([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code),
   [obtain user access token](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
4. A version 2 event or callback carries `header.app_id` and
   `header.tenant_key` after parsing/decryption. An encrypted HTTP delivery,
   however, exposes only an `encrypt` envelope before decryption, so a shared
   endpoint cannot safely choose among many apps' Encrypt Keys from the body.
   ([callback structure](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/callback-overview),
   [encrypted Webhook delivery](https://open.feishu.cn/document/event-subscription-guide/event-subscriptions/event-subscription-configure-/choose-a-subscription-mode/send-notifications-to-developers-server?lang=zh-CN))
5. A WebSocket/long-connection client is authenticated with one app's App ID
   and App Secret. Therefore its local client instance is the pre-authenticated
   source-app route. It is not a process-global “all customer apps” listener.
   ([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
6. Feishu documents `chat_id` as the conversation identifier, but the cited
   documentation does not promise that a raw `chat_id` is globally unique
   across every tenant and app. Chat APIs explicitly reject an operator and
   chat from different tenants and often require the app bot to be in the
   chat. A bot is represented by its application's `app_id`.
   ([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology),
   [get chat members](https://open.feishu.cn/document/server-docs/group/chat-member/get?lang=zh-CN),
   [message resource](https://open.feishu.cn/document/server-docs/im-v1/message/intro))

## 1. Application and tenant boundary

### Official facts

- `app_id` is the immutable identifier generated by Feishu for an application.
  `tenant_key` is the enterprise identifier; one `tenant_key` corresponds to
  one enterprise in actual use.
  ([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology))
- An enterprise custom app is restricted to one tenant. Its developers,
  administrators, and users belong to that tenant, and another organization
  cannot use it. A store app is the contrasting model that can be installed by
  multiple tenants.
  ([custom apps versus store apps](https://open.feishu.cn/document/home/app-types-introduction/self-built-apps-and-store-apps))
- Access tokens tell Feishu which app/user is calling and which tenant's data
  is being accessed. A `tenant_access_token` calls as the application and its
  data range is determined by that application's permissions.
  ([access-token overview](https://open.feishu.cn/document/server-docs/api-call-guide/calling-process/get-access-token))
- A custom app obtains its `tenant_access_token` using that app's `app_id` and
  `app_secret`.
  ([custom-app tenant access token](https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN))

### Derived requirements

- Model a durable `FeishuAppConnection` (or equivalent) owned by exactly one
  Hub Organization. At minimum it binds the internal connection ID,
  `organization_id`, `app_id`, expected `tenant_key`, encrypted `app_secret`,
  event/callback credentials, lifecycle state, and credential version.
- Do not select Feishu credentials from a process-global default. Every OAuth
  exchange, token cache entry, OpenAPI call, Webhook verification, and
  long-connection client must start from the Organization's connection.
- Pin the first verified `tenant_key` to the connection. Later login, event,
  and callback data must match both the expected `app_id` and pinned
  `tenant_key`; unknown or mismatched pairs must be rejected rather than
  auto-attached to another Organization.

## 2. User identifier scopes

### Official scope matrix

| Identifier | Feishu's documented scope | Cross-app/tenant behavior | Availability caveat |
| --- | --- | --- | --- |
| `open_id` | User identity inside one application | The same user has a different `open_id` in different apps | The normal app-relative user identifier |
| `user_id` | User identity inside one tenant | Stable across all apps in the same tenant; different in tenant A and tenant B | Reading it is a sensitive field permission and is limited to custom apps in the cited user API |
| `union_id` | User identity across apps from the same application provider/developer | Same across that provider's apps; different across different providers | It does not make unrelated customer-owned apps one identity domain |
| `tenant_key` | Enterprise identity | One key corresponds to one enterprise | Returned by login and carried in events/callbacks |

Sources:

- Feishu's current user resource overview states that `user_id` is tenant
  identity, `open_id` is app identity, and `union_id` is identity across apps
  developed by the same application provider. It expressly says the same
  user's Open ID differs between apps.
  ([user resource overview](https://open.feishu.cn/document/server-docs/contact-v3/user/field-overview?lang=zh-CN))
- Feishu's API parameter text is more explicit: the same user's User ID differs
  between tenants but remains the same across apps within one tenant; the same
  user's Union ID is the same under one developer and different under another.
  ([official ID parameter definitions](https://open.feishu.cn/document/server-docs/contact-v3/department/search?lang=zh-CN))
- Feishu describes Union ID as the identity linking one user across multiple
  applications supplied by the same application service provider.
  ([Union ID guide](https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-obtain-union-id))
- Feishu's common-error guidance says not to use an Open ID obtained from app A
  in app B, and even a test app and its production app produce different Open
  IDs for the same user. It also reports cross-tenant errors for both User ID
  and Union ID use.
  ([common errors](https://open.feishu.cn/document/ukTMukTMukTM/ugjM14COyUjL4ITN?op_tracking=hc))
- The current login user-info API returns `open_id`, `union_id`, optional
  sensitive `user_id`, and `tenant_key` from a `user_access_token`.
  ([get login user information](https://open.feishu.cn/document/server-docs/authentication-management/login-state-management/get?lang=zh-CN))

### Answer: can different customer Apps share one global `open_id` key?

**No.** This directly contradicts Feishu's documented Open ID semantics. Two
customer-owned custom apps are different applications, so the same person has
different Open IDs in them. Because those apps are independently owned custom
apps, the Hub also has no documented basis to assume they are applications of
one application provider and therefore no basis to auto-merge on `union_id`.
`user_id` cannot solve cross-customer identity either because it changes across
tenants.

Derived identity model:

- The safe external login key is `(feishu_app_connection_id, open_id)`; retain
  `tenant_key`, `user_id`, and `union_id` as scoped attributes, not as an
  unqualified SaaS-global primary key.
- If the product needs one Hub human account to participate in multiple
  Organizations, represent each Feishu login as a separate external identity
  attached through an explicit, separately verified account-linking flow. Do
  not silently merge on Open ID, Union ID, email, phone, or display name.
- Do not make `user_id` mandatory for login unless every customer app is
  required to obtain its sensitive field permission. `open_id` and
  `tenant_key` are returned by the user-info flow without using `user_id` as
  the login key.
- Replacing an Organization's Feishu App changes the application identity
  domain and therefore its users' Open IDs. Treat app replacement as an
  explicit connection migration/re-link operation, not an in-place secret
  edit.

## 3. OAuth login must be app-routed

### Official facts

- The authorization URL requires `client_id`, which is the selected
  application's App ID. `redirect_uri` must already appear in that
  application's security settings. On success Feishu redirects with `code`
  and, when supplied, the original `state`.
  ([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code))
- Feishu says `state` maintains request/callback context and must be compared
  to prevent CSRF. Its official sample rejects a callback whose state differs
  from the stored state.
  ([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code),
  [user-access-token sample](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
- The code lasts five minutes and can be used once. Token exchange requires
  `client_id`, `client_secret`, `code`, and, when used during authorization,
  the same `redirect_uri`. Feishu defines error 20024 for a code/refresh token
  that does not match the supplied client ID and error 20071 for a mismatched
  redirect URI.
  ([obtain user access token](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
- Only redirect URLs configured for the selected application pass Feishu's
  security check; an app may configure multiple URLs.
  ([access-token acquisition flow](https://open.feishu.cn/document/server-docs/api-call-guide/calling-process/get-access-token))

### Derived request flow

1. Begin at an Organization-qualified route or invitation, resolve exactly one
   active `FeishuAppConnection`, and create a short-lived, one-time server-side
   login transaction bound to that Organization, connection, redirect URI,
   desired return path, and PKCE verifier if used.
2. Put only a high-entropy opaque transaction nonce in OAuth `state`; generate
   the authorization URL with that connection's `app_id` as `client_id`.
3. On callback, atomically consume and validate `state` before selecting
   credentials. The callback's untrusted query parameters must never be
   allowed to choose another Organization or App.
4. Exchange `code` with the connection's `app_id`, decrypted `app_secret`, and
   the exact stored redirect URI. Then call user-info and require its
   `tenant_key` to equal the connection's pinned tenant.
5. Resolve or create the membership through
   `(feishu_app_connection_id, open_id)`. Never search all Organizations for a
   bare Open ID and accept the first match.

A single physical callback URL may be configured independently in multiple
apps, but a shared URL does not remove the need for state-bound app routing.

## 4. HTTP event and callback routing

### Official facts

- A version 2 callback header contains the application's Verification Token,
  `event_type`, `tenant_key` (the callback application's tenant), and `app_id`
  (the callback application's App ID).
  ([callback overview and structure](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/callback-overview))
- Version 2 event examples likewise place `app_id` and `tenant_key` in the
  event header.
  ([user-leaves-chat event](https://open.feishu.cn/document/server-docs/group/chat-member/event/deleted-2))
- Verification Token is an application validation identifier. Feishu says the
  server can compare the event's token with that application's token to check
  that a request is for the specified app. With an Encrypt Key, the request
  signature is calculated from request timestamp, nonce, that app's Encrypt
  Key, and the original request body.
  ([receive and verify events](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/encrypt-key-encryption-configuration-case))
- When an Encrypt Key is configured, the HTTP request body visible before
  decryption is an envelope containing only `{"encrypt":"..."}`; `app_id`
  and `tenant_key` are inside the ciphertext.
  ([send events to developer server](https://open.feishu.cn/document/event-subscription-guide/event-subscriptions/event-subscription-configure-/choose-a-subscription-mode/send-notifications-to-developers-server?lang=zh-CN))

### Derived routing and verification requirements

- Give every Feishu connection a stable, unguessable callback route such as a
  connection-specific path token. Resolve the connection from that route,
  load exactly its Encrypt Key/Verification Token, verify and decrypt, then
  require the authenticated payload's `app_id` and `tenant_key` to match the
  route's connection.
- For an unencrypted common endpoint, `header.app_id` may identify a candidate
  connection, but it is untrusted until that connection's signature/token has
  been verified. Never route the side effect solely from an unverified body.
- A single encrypted URL shared by every customer app has no documented
  pre-decryption app selector. Trying every tenant's secret is not an identity
  protocol. Use connection-specific routing instead.
- Namespace delivery deduplication by the authenticated connection (for
  example `(connection_id, event_id)`), and dispatch business work only after
  app/tenant verification. Unknown, disabled, or mismatched connections fail
  closed and produce an auditable security log.

## 5. Long-connection routing

### Official facts

- Feishu's SDK creates a WebSocket client with one app's required `APP_ID` and
  `APP_SECRET`. Events subscribed by that application are then delivered over
  that channel. Authentication is performed when the connection is
  established.
  ([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
- Long-connection event subscription supports enterprise custom apps, not
  store apps. Each app may establish at most 50 connections. If one app has
  multiple clients, delivery is cluster-mode rather than broadcast: one random
  client receives a message.
  ([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
- Long-connection callbacks use the same per-app App ID/App Secret client
  construction and have the same cluster-mode single-recipient behavior.
  ([long-connection callback subscription](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/step-1-choose-a-subscription-mode/configure-callback-request-address?lang=zh-CN))

### Derived routing requirements

- Create one logical client binding per enabled `FeishuAppConnection`, even if
  an in-process supervisor manages many clients. Attach immutable
  `organization_id` and `connection_id` context to each handler; the client
  that received the frame is the pre-authenticated application route.
- Never infer the receiving Organization by looking up a bare actor Open ID or
  chat ID. First use the authenticated client binding; then cross-check any
  delivered `app_id`/`tenant_key` fields available in the event structure.
- Scaling several replicas for the same app does not create duplicate
  broadcast processing according to Feishu's contract, but delivery still
  requires application-level idempotency and health ownership. Capacity must
  count clients per app against the documented 50-connection ceiling.

## 6. Bot, chat, and message identifiers

### Official facts

- In the message resource, a sender can be a user or an application. For an
  application sender, `id_type` is `app_id`; the sender also carries a
  `tenant_key`. Thus the Feishu bot identity used by messaging is its
  application identity, not a Hub-global bot ID.
  ([message management overview](https://open.feishu.cn/document/server-docs/im-v1/message/intro))
- `chat_id` identifies a conversation, including direct and group chats.
  Feishu's group resource also exposes the chat's `tenant_key`.
  ([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology),
  [group management overview](https://open.feishu.cn/document/server-docs/group/chat/intro?lang=zh-CN))
- Chat APIs require the operator to be in the same tenant as an internal chat;
  common errors include `Operator and chat can NOT be in different tenants`.
  Many operations additionally require the calling app's bot to be in the
  group and reject an app that is unavailable in that tenant.
  ([get chat members](https://open.feishu.cn/document/server-docs/group/chat-member/get?lang=zh-CN))
- The first bot direct-chat event calls `chat_id` the conversation ID between
  **that robot and user** and includes `app_id`, `tenant_key`, and app-scoped
  user Open IDs in the same event.
  ([user and bot first conversation](https://open.feishu.cn/document/server-docs/group/chat-member/event/bot-events?lang=zh-CN))

### What the official sources do not establish

The cited documentation calls `chat_id` a conversation/group identifier but
does not state that its value is globally unique across every Feishu tenant and
every independently created app. It does establish tenant and bot-membership
authorization boundaries. Under the repository rule that unspecified scope
must not be assumed, raw global uniqueness would be an unsupported inference.

Derived storage/call rules:

- Store chats as `(feishu_app_connection_id, chat_id)` and retain observed
  `tenant_key` and chat type. Do the same for other resource identifiers unless
  Feishu explicitly contracts a wider scope.
- Always call chat/message APIs with the token obtained from the chat's bound
  connection. Never retry an authorization failure with another Organization's
  token.
- Treat bot direct chats as connection-specific. A `chat_id` for user X's
  conversation with Organization A's bot is not evidence of a conversation
  with Organization B's bot.
- For group chats, two customer apps may both be members of a real Feishu
  group, but that does not authorize the Hub to move configuration, Projects,
  or memberships between Organizations. The authenticated connection remains
  the tenant route and Hub Organization remains the authorization root.

## Minimum invariants for implementation review

1. `FeishuIdentity` uniqueness is `(connection_id, open_id)`, never bare
   `open_id`.
2. OAuth `state` resolves one server-side transaction bound to one Organization
   and connection; code exchange uses only that connection's credentials and
   stored redirect URI.
3. Login accepts the returned identity only when `tenant_key` matches the
   connection's pinned tenant.
4. HTTP Webhooks choose a connection-specific verification/decryption context
   before trusting payload routing fields; authenticated `app_id` and
   `tenant_key` must match it.
5. Every long-connection handler carries the local connection/Organization
   binding of the App ID/App Secret client that received the frame.
6. Bot, chat, event-deduplication, token-cache, and user identifiers are all
   namespaced by the Feishu connection unless Feishu explicitly guarantees a
   wider scope.
7. A credential, app, or tenant mismatch is a hard failure with traceable
   logging; there is no fallback to a default app or search across tenant
   secrets.
