# Authorization in a Black Hole app

Black Hole is a closed server mode for the app: from the outside it is not directly reachable — the only entry is through the platform gateway (Gateway). When an employee opens the app inside Bitrix24 in one of the [placements](/docs/apps/placements), the Gateway checks who they are and puts the user data straight into the headers of the request to the app server. The app needs neither its own login form nor password storage: it trusts the headers from the Gateway and, when it needs Bitrix24 account data, calls the API on behalf of that user. Who can open the app is set by the access policy — [Access and modes](/docs/infra/access).

The Gateway itself authenticates the user and forwards an already-signed request to the app. The browser never sees or stores `vibe_session_*` — the token lives only between the Gateway and the app server. The app receives the `X-Vibe-Authorization: Bearer vibe_session_<…>` header and reads user data through `GET /v1/me`. This is the BFF pattern (Backend-for-Frontend) — the same one used by Cloudflare Access, Google IAP, and AWS API Gateway.

Scope: `vibe:apps` (on the V1 API side); inside the tunnel there is no explicit scope — the Gateway manages access itself.

[Request headers](#what-arrives-in-every-request) · [iframe parameters](#iframe-url-parameters-after-the-placement-redirect) · [User data](#getting-user-data-get-v1-me) · [Code examples](#code-examples) · [Session lifecycle](#session-lifecycle)

## Request lifecycle

The full exchange — from opening the app in Bitrix24 to proxying the request to the app server — is shown below step by step. A breakdown of the key points follows right after the diagram.

```
Bitrix24 placement                        Backend (vibecode.bitrix24.com)
   │                                         │
   ├── POST /v1/bitrix-handler ──────────────►│  exchange OAuth code,
   │     (B24 placement triple)               │  upsert AppUserToken,
   │                                          │  createAppSession (mint vibe_session_*),
   │                                          │  mint signed __init JWT (TTL 5 min)
   │                                          │
   │◄── 302 https://app-XXX/?placement=…&member_id=…[&placement_options=…]&__init=<jwt> ──┤
   │
Browser
   │
   ├── GET https://app-XXX/?placement=…&member_id=…[&placement_options=…]&__init=<jwt> ───►│ Gateway
   │                                                                     │  Priority 0 redeem:
   │                                                                     │   • verify JWT (HMAC-SHA256, "gateway-init")
   │                                                                     │   • subdomain binding
   │                                                                     │   • mark jti used (one-time)
   │                                                                     │   • store (sub, subdomain) → raw in cache
   │                                                                     │   • mint _vibe_gw cookie
   │                                                                     │
   │◄────────── 302 https://app-XXX/?placement=…&member_id=…[&placement_options=…] ───────┤
   │                                          Set-Cookie: _vibe_gw=…
   │
   ├── GET https://app-XXX/?placement=…&member_id=…[&placement_options=…] ──────────────►│ Gateway
   │   Cookie: _vibe_gw=…                                                │  Priority 1 (cookie):
   │                                                                     │   • resolve raw from cache
   │                                                                     │   • strip incoming X-Vibe-Authorization (anti-spoof)
   │                                                                     │   • inject X-Vibe-Authorization: Bearer vibe_session_…
   │                                                                     │
   │                                                                     ├── GET / ─────► App server
   │                                                                     │   X-Vibe-Authorization: Bearer vibe_session_…
   │                                                                     │   Cookie: _vibe_gw=…  (Gateway-only; not for the app)
   │                                                                     │
   │◄────────────────────── HTTP response ───────────────────────────────┤
```

Key difference from standard OAuth:

- **The browser never sees `vibe_session_*`** — not even on the very first open. The token travels only in a signed one-time code in the URL, which the Gateway immediately exchanges and erases.
- **`__init` is one-time**. Re-exchanging the same `jti` is rejected. Its lifetime is 5 minutes. On slow networks this is more than enough for the iframe to load — if you do not make it within 5 minutes, check the Gateway logs `init code replay rejected` / `init code verify failed`.
- **The `_vibe_gw` cookie** lives 10 minutes — around 40 minutes for placement apps (apps embedded in Bitrix24) — and is renewed on activity. Do not try to read it in JS — it is `HttpOnly`.
- **Waking from sleep**: if the server is `SLEEPING`, the Gateway exchanges `__init` **before** the wake page, stores the original data in the cache, and sets the cookie in the response. After the agent reconnects, the browser does a `location.replace` to a clean URL — the cache has not expired yet, and `X-Vibe-Authorization` is set on the very first GET.

## What arrives in every request

```
GET /api/tasks
Host: app-9edac24091ba.vibecode.bitrix24.com
Cookie: _vibe_gw=<…>                       ← platform cookie, forwarded to the app transparently

X-Vibe-Request-Id:   req_<16 hex chars>
X-Vibe-User-Id:      42
X-Vibe-Portal-Id:    f2342f7a-b1c5-4d50-8a3e-30219c5ae0c1
X-Vibe-User-Name:         Jürgen Müller
X-Vibe-User-Name-Encoded: J%C3%BCrgen%20M%C3%BCller
X-Vibe-User-Role:    MEMBER
X-Vibe-Authorization: Bearer vibe_session_<43 chars>
```

The Gateway sets seven headers on every proxied request. `X-Vibe-Request-Id` arrives on any request; the rest only when the user is authenticated (via the placement flow or a Bearer token):

| Header | When | Value |
|-----------|-------|----------|
| `X-Vibe-Request-Id` | always | Request identifier for correlation in logs. Prefix `req_` + 16 hex characters. |
| `X-Vibe-User-Id` | authenticated request | Numeric Bitrix24 user ID (for example, `42`). Matches `currentUser.bitrixUserId` in the `/v1/me` response. |
| `X-Vibe-Portal-Id` | authenticated request | The Bitrix24 portal UUID in Vibecode's internal DB. Do not confuse it with the domain — that is separate. |
| `X-Vibe-User-Name` | authenticated request | The user's display name as raw UTF-8 bytes. ⚠️ Most HTTP stacks (Express and others) read header values as latin1 (RFC 7230 §3.2.4), so localized names arrive as mojibake (`JÃ¼rgen` instead of `Jürgen`). For a correct name, use `X-Vibe-User-Name-Encoded` (below). The header is kept for backward compatibility. |
| `X-Vibe-User-Name-Encoded` | authenticated request | The same display name, percent-encoded per RFC 3986 (UTF-8). Decoded by any standard decoder: in JS — `decodeURIComponent(req.headers['x-vibe-user-name-encoded'])`. **The recommended source for the name** instead of the raw `X-Vibe-User-Name`. |
| `X-Vibe-User-Role` | authenticated request | `ADMIN` or `MEMBER` — the role of the current user (the one who opened the app) in the Bitrix24 account. `ADMIN` means the right to manage the app's settings (the Bitrix24 `user.admin` predicate) — including for the Bitrix24 account owner. This is a reliable way to tell whether the **current** user is an administrator. Bitrix24 does not return an administrator flag for an arbitrary user from `GET /v1/users` (see the `isAdmin` field in the users entity docs). |
| `X-Vibe-Authorization` | authenticated request | `Bearer vibe_session_<…>` — the access token for server-side V1 API calls. |

- **`X-Vibe-Authorization`** is the single source of the Bearer token. The platform `X-Vibe-` prefix was chosen so as not to conflict with the app's own `Authorization` chain (for example, if it proxies to its own upstream API). The Gateway strips any incoming `X-Vibe-Authorization` from the client before forwarding — the app will never receive a forged token.
- **`X-Vibe-User-Id` / `-Portal-Id` / `-User-Name` / `-User-Role`** are a fast way to identify the user without an extra `/v1/me` call. They are suitable for logs, analytics, and simple ACL. For full context (`scopes`, `capabilities`, `tariff`) still make a single `/v1/me` call and cache it per token.
- **For the user name — read `X-Vibe-User-Name-Encoded`, not `X-Vibe-User-Name`.** The raw `X-Vibe-User-Name` carries UTF-8 bytes that HTTP stacks treat as latin1 → localized names arrive as mojibake. The correct name: `const name = decodeURIComponent(req.headers['x-vibe-user-name-encoded'] ?? '')`. The old header is left as is so as not to break apps already using the `Buffer.from(name, 'latin1').toString('utf8')` workaround.
- **`X-Vibe-Request-Id`** — put it in your structured log to trace the request between the Gateway and the app. When contacting Vibecode support, send this ID — it is in the platform logs too.
- **`Cookie: _vibe_gw`** — left as is, but it is not a V1 API access token. It is the Gateway session token for fast authorization of subsequent requests. The app does not need it and it is marked `HttpOnly` — JS will not even see it.
- **`access_token` / `auth[*]` in the request body** — these do not arrive. Do not try to read them.

## iframe URL parameters after the placement redirect

After `POST /v1/bitrix-handler` the platform redirects (302) the browser to `<appUrl>` and adds to the query string:

| Parameter | When | Value |
|----------|-------|----------|
| `placement` | always | The placement code (`LEFT_MENU`, `CRM_DEAL_DETAIL_TAB`, `IM_TEXTAREA`, …). Matches `PLACEMENT` in the POST from B24. |
| `member_id` | always | The Bitrix24 account identifier (the same `member_id` as in B24 OAuth). |
| `placement_options` | when B24 sends PLACEMENT_OPTIONS — typical for DETAIL_TAB / DETAIL_TOOLBAR / LIST_MENU and other placements that have entity context | **A JSON string with the entity context** — for example `{"ID":"42"}` for CRM_DEAL_DETAIL_TAB. Here B24 puts the ID of the current card (deal/lead/contact/company). |
| `__init` | first request | A one-time JWT for the Gateway, exchanged for the `_vibe_gw` cookie. After the exchange it disappears (the URL is replaced with a clean one). |

**A note on naming:** B24 sends this field as `PLACEMENT_OPTIONS` (uppercase) in the POST to `/v1/bitrix-handler`. We forward it under the name `placement_options` in lowercase in the iframe URL. It is the same field — look it up in the B24 specification by its uppercase name.

**How to read `placement_options`** — take it from `URLSearchParams` and parse the JSON:

```js
const params = new URLSearchParams(window.location.search)
const placement = params.get('placement')                  // "CRM_DEAL_DETAIL_TAB"
const options = JSON.parse(params.get('placement_options') || '{}')
const entityId = options.ID                                // "42" — the current card
```

**Do not use `BX24.placement.info()`** — the SDK does not see the context when redirected through the platform handler and will return `{placement:null, options:null}`. Everything you need about the placement and its parameters is already in the URL.

## Opening Bitrix24 entities from the app

The app lives on its own subdomain inside an iframe, while the cards of deals, tasks, and the other entities live on the Bitrix24 account. The supported way to open a card: send the top window to its address on a user click.

```js
// portal — the domain the app server got from GET /v1/me.
// Inside a click handler only. Top-window navigation from a nested iframe needs
// transient user activation: it is absent on page load, and after an await it
// may have expired or been consumed by another API — the navigation won't fire.
button.addEventListener('click', () => {
  window.top.location.href = `https://${portal}/crm/deal/details/${dealId}/`
})
```

**Take the domain for such a link from a trusted source only, and there is exactly one: the `portal` field of the `GET /v1/me` response.** That is an answer from the platform, bound to your app's session — nobody can slip a foreign domain into it from the outside.

`document.location.ancestorOrigins[0]` does not fit this job, even though the browser fills the list in itself. Its first element is the origin of the immediate parent, which is not necessarily the account: inside a nested frame the parent turns out to be an intermediate site, and the link takes the top window there. Checking `ancestorOrigins` against the domain from `/v1/me` makes no sense either — once you hold the trusted answer, take the domain straight from it.

**Do not treat your own app's query string as a trusted source.** It is composed by whoever opened the link rather than by the platform, and anyone can put anything into it — a plausible-looking domain included. Top-window navigation takes the user wherever the domain points, so an app that trusts the domain from its own URL sends the user to someone else's site from an address the user trusted.

The regular placement redirect does not pass the account domain — it carries `placement`, `member_id`, `placement_options` and `__init` only (a self-hosted app gets `code` instead of `__init`). But a domain that did end up in the query string is no sign that the platform put it there: it could have arrived any number of ways, and it still has to be checked against a trusted source.

**This is navigation, not a slider on top of the app.** The top window leaves for a Bitrix24 page, and the app unmounts along the way. To come back — navigate back by the means of Bitrix24 itself.

Example addresses:

| Entity | Address |
|--------|-------|
| Deal | `/crm/deal/details/<id>/` |
| Lead | `/crm/lead/details/<id>/` |
| Contact | `/crm/contact/details/<id>/` |
| Company | `/crm/company/details/<id>/` |
| Quote | `/crm/type/7/details/<id>/` |
| Invoice | `/crm/type/31/details/<id>/` |
| Smart-process item | `/crm/type/<entityTypeId>/details/<id>/` |
| Task | `/company/personal/user/<responsibleId>/tasks/task/view/<id>/` |

The list is advisory rather than exhaustive: the platform does not restrict where you navigate — any account page that opens on its own will do.

**The native BX24 JS SDK does not work if the app is opened through the Vibecode handler.** `BX24.init` never completes, and with it `BX24.openPath` and `BX24.openSlider` are unavailable. The reason is the same as for `BX24.placement.info()` above: after the redirect of the handler the frame is open on a different origin and without the Bitrix24 context that the SDK expects from the parent window. Hence — top-window navigation.

That is how every Vibecode app opens — both on Black Hole and with an `appUrl` on your own server: in both cases the placement arrives at the platform handler, which redirects the frame to the app address. **The exception is a Bitrix24 app of your own with its own handler** (see [Self-hosted app](#self-hosted-app-not-black-hole)): the placement arrives straight at your origin, there is no redirect, the SDK context is in place — `BX24.init` works there as usual and this section is not needed.

## iframe auto-height

Inside Bitrix24 the app is opened in an embedded iframe whose height is set by Bitrix24, not by the content: if the page is taller than the window, the bottom is clipped and the iframe has no scrollbar of its own. To have the platform grow the iframe to fit the content, the app reports its own height — it posts a message to its **parent window** (`window.parent`, not `window.top`). The platform wrapper catches that message and relays the height to Bitrix24, which resizes the placement.

```js
// Recompute whenever the content changes — a ResizeObserver on the app root is the most reliable trigger.
// targetOrigin MUST be '*' (or the wrapper origin) — see below for why not the portal domain.
const height = document.documentElement.scrollHeight
window.parent.postMessage({ type: 'vibe:resize', height }, '*')
```

**`targetOrigin` — `'*'` or the wrapper origin, but not the portal domain.** The browser checks the sender's `targetOrigin` against the recipient window's origin before delivery. Under the wrapper the app's immediate parent is the wrapper itself, not the account, so a hardcoded portal domain in `targetOrigin` makes the browser drop the message before the wrapper ever sees it — and that can no longer be fixed platform-side. `'*'` is safe: the wrapper still checks the sender's origin before it changes the height.

**What is accepted.** The `type` field is `vibe:resize` or `vibe:setHeight`; the height is a **number** of pixels in the `height` field, and a non-numeric value is dropped. The wrapper does not measure your content for you — the app always carries the height, so a message without a numeric height does nothing.

**`ancestorOrigins[0]`, once more.** Under the wrapper the warning from the "Opening Bitrix24 entities from the app" section becomes literal: `document.location.ancestorOrigins[0]` is the wrapper origin (`vibecode.bitrix24.com`), not the account domain. Still take the domain only from the `GET /v1/me` response (the `portal` field) — the one trusted source.

## Getting user data (`GET /v1/me`)

`X-Vibe-Authorization` carries only the access token, not user data. To find out the user, the Bitrix24 account, the scopes, and the `capabilities`, call `/v1/me` once on the app server and cache the result in your own session:

```
GET https://vibecode.bitrix24.com/v1/me
X-Api-Key: <your-app-key>
Authorization: Bearer vibe_session_<…>     ← taken from X-Vibe-Authorization

200 OK
{
  "success": true,
  "data": {
    "type": "oauth_app",
    "portal": "company.bitrix24.com",
    "scopes": ["crm", "tasks", "imbot", ...],
    "currentUser": {
      "bitrixUserId": "42",
      "_note": "B24 numeric user id of the OAuth-authorized end user (from Bearer session)."
    },
    "capabilities": { "servers": { "create": { "available": true, "reason": "TRIAL_ACTIVE" }, ... }, ... },
    "tariff": { ... },
    "app": { "title": "My App", "id": "<uuid>" }
  }
}
```

Representative fields are shown. The response also includes `api` (the list of available endpoints and entities), `oauth`, `oauthTutorial`, `deployment`, `infra`, `feedback`, and others — the model uses them to build requests without reading the rest of the documentation.

**The user identifier is `data.currentUser.bitrixUserId`** (a string with the numeric Bitrix24 ID). Not `userId`, not `user.id`, not a top-level field — exactly `currentUser.bitrixUserId`. It matches the `X-Vibe-User-Id` header (see the "What arrives in every request" section) that the Gateway already set on the request. For simple identification the header is enough. `/v1/me` is needed when you require `scopes`, `capabilities`, `tariff`, or app information.

**Without `Authorization: Bearer`** the same `/v1/me` responds with **the same set of fields**, only `currentUser` will be `null`. This is a supported mode: it is used for server-side initialization of the app before OAuth — the Bitrix24 account, scopes, and capabilities are already visible. Do not try to detect "no session" by a 401 — there will be none; check `currentUser !== null`.

The cache has a 24-hour TTL. After a 401 on V1, send the user back to the placement (see below).

## The app as a service: its own key (X-Api-Key)

Everything above is the **per-user** model: an app inside Bitrix24 runs as the current user, forwards `X-Vibe-Authorization`, and reads `/v1/*` with that user's rights. It needs an OAuth-app `vibe_app_` key (created in the "Authorization keys" section) plus the forwarded Bearer session — a personal `vibe_api_` key cannot carry a Bearer session or bind a placement.

But an app can also be a **service**: a server, backend or dashboard that calls `/v1/*` itself, without a user session (for example, a company-wide report). Then it authenticates with its own personal `vibe_api_` key in the `X-Api-Key` header. The user creates the key in the [Keys](/docs/keys-auth) section (`/keys`), passes it to the app via the `env` parameter at [deploy](/docs/infra/deploy) time (for example `VIBE_API_KEY`), and the app reads it from the environment and sends it as `X-Api-Key`. There is no auto-generated "server key".

**How to choose the flow.** If you show data that depends on the current user, use the per-user flow above (forward `X-Vibe-Authorization`): a single shared `X-Api-Key` is read as the **key owner**, not the current user, so every visitor sees the owner's data. A personal key talks to Bitrix24 as its owner, so for a service dashboard keep the server access policy at `OWNER_ONLY` (see [Access policy](/docs/infra/access/access-policy)) and choose the key owner deliberately; for a read-only report, mint the key in READONLY mode with minimal scopes.

## Code examples

### Node.js / Express

```js
import express from 'express'
const app = express()

const VIBE_API = 'https://vibecode.bitrix24.com'
const VIBE_APP_KEY = process.env.VIBE_APP_KEY  // your vibe_app_<…> key

app.use(async (req, _res, next) => {
  // The Gateway already checked everything — but let's be safe just in case
  const bearer = req.headers['x-vibe-authorization']?.toString().replace(/^Bearer /, '')
  if (!bearer) return next() // anonymous (PUBLIC policy) — cache without identity

  // Cache identity in-process per token (one /v1/me call per session).
  // /v1/me returns { success, data: { currentUser, portal, scopes, ... } } —
  // unwrap the envelope to work with the fields directly afterward.
  let me = identityCache.get(bearer)
  if (!me) {
    const res = await fetch(`${VIBE_API}/v1/me`, {
      headers: { 'X-Api-Key': VIBE_APP_KEY, Authorization: `Bearer ${bearer}` },
    })
    if (!res.ok) return next() // 401 → show the re-authentication screen
    const json = await res.json()
    me = json.data
    identityCache.set(bearer, me)
  }
  req.user = me           // me.currentUser?.bitrixUserId, me.portal, me.scopes, me.capabilities
  req.bearer = bearer
  next()
})

app.get('/api/tasks', async (req, res) => {
  // currentUser may be null if the request arrived without a Bearer (PUBLIC policy).
  // The alternative is to read the X-Vibe-User-Id header (the Gateway already set it).
  const userId = req.user?.currentUser?.bitrixUserId ?? req.headers['x-vibe-user-id']
  const r = await fetch(`${VIBE_API}/v1/tasks?filter[RESPONSIBLE_ID]=${userId}`, {
    headers: { 'X-Api-Key': VIBE_APP_KEY, Authorization: `Bearer ${req.bearer}` },
  })
  res.json(await r.json())
})
```

### Python / FastAPI

```python
from fastapi import FastAPI, Request, HTTPException
import httpx

VIBE_API = "https://vibecode.bitrix24.com"
VIBE_APP_KEY = os.environ["VIBE_APP_KEY"]
app = FastAPI()
identity_cache: dict[str, dict] = {}

async def resolve_identity(request: Request):
    raw = request.headers.get("x-vibe-authorization", "")
    bearer = raw.removeprefix("Bearer ")
    if not bearer:
        return None
    if bearer in identity_cache:
        return identity_cache[bearer]
    async with httpx.AsyncClient() as c:
        r = await c.get(
            f"{VIBE_API}/v1/me",
            headers={"X-Api-Key": VIBE_APP_KEY, "Authorization": f"Bearer {bearer}"},
        )
    if r.status_code != 200:
        return None
    # /v1/me returns {"success": True, "data": {...}} — store exactly data in the cache.
    me = r.json()["data"]
    identity_cache[bearer] = me
    return me

@app.get("/api/tasks")
async def tasks(request: Request):
    me = await resolve_identity(request)
    if not me:
        raise HTTPException(401, "Re-authentication required")
    # currentUser may be None for a request without a Bearer (PUBLIC policy); fall back to the Gateway header.
    user_id = (me.get("currentUser") or {}).get("bitrixUserId") or request.headers.get("x-vibe-user-id")
    async with httpx.AsyncClient() as c:
        r = await c.get(
            f"{VIBE_API}/v1/tasks",
            params={"filter[RESPONSIBLE_ID]": user_id},
            headers={"X-Api-Key": VIBE_APP_KEY,
                     "Authorization": request.headers["x-vibe-authorization"]},
        )
    return r.json()
```

### Go / net-http

```go
package main

import (
    "encoding/json"
    "net/http"
    "os"
    "strings"
    "sync"
)

const vibeAPI = "https://vibecode.bitrix24.com"

var vibeAppKey = os.Getenv("VIBE_APP_KEY")
var identityCache sync.Map // bearer string -> *Identity

// CurrentUser matches the fields of the data.currentUser object in the /v1/me response.
// When the request arrived without a Bearer (PUBLIC policy) — data.currentUser comes as null,
// and the CurrentUser field in the decoded struct stays empty (BitrixUserID == "").
type CurrentUser struct {
    BitrixUserID string `json:"bitrixUserId"`
}

type Identity struct {
    Portal      string       `json:"portal"`       // the portal domain, for example "company.bitrix24.com"
    Scopes      []string     `json:"scopes"`
    CurrentUser *CurrentUser `json:"currentUser"`  // nil without a Bearer
}

type meEnvelope struct {
    Success bool     `json:"success"`
    Data    Identity `json:"data"`
}

func resolveIdentity(r *http.Request) (*Identity, string, error) {
    raw := strings.TrimPrefix(r.Header.Get("X-Vibe-Authorization"), "Bearer ")
    if raw == "" {
        return nil, "", nil
    }
    if v, ok := identityCache.Load(raw); ok {
        return v.(*Identity), raw, nil
    }
    req, _ := http.NewRequest("GET", vibeAPI+"/v1/me", nil)
    req.Header.Set("X-Api-Key", vibeAppKey)
    req.Header.Set("Authorization", "Bearer "+raw)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, "", err
    }
    defer resp.Body.Close()
    if resp.StatusCode != 200 {
        return nil, "", nil
    }
    var env meEnvelope
    if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
        return nil, "", err
    }
    identityCache.Store(raw, &env.Data)
    return &env.Data, raw, nil
}

// Usage example: r.User.CurrentUser.BitrixUserID — the numeric Bitrix24 user ID.
// The alternative is the r.Header.Get("X-Vibe-User-Id") header, which the Gateway set on entry.
```

## Session lifecycle

Two lifetimes are in effect at once, and they should not be confused:

- **The `vibe_session_*` token lives 24 hours** (`AppSession.expiresAt`). No refresh is done — after it expires the user re-opens the placement.
- **The Gateway session (`_vibe_gw`) lives 10 minutes**, and around **40 minutes** for placement sessions (the app embedded in Bitrix24). It is renewed on activity: a request to the app server in the session's second half (for a regular session — when less than 5 minutes remain; for a placement one — roughly after the 20th minute) gets a fresh cookie. As long as the app keeps receiving requests, the session holds and the `X-Vibe-Authorization` header is set automatically.

This has a consequence worth keeping in mind: **a `401` can arrive earlier than the token's 24 hours run out** — if the app server receives no request for longer than the Gateway session's lifetime (around 40 minutes for placement apps, 10 for the rest). For example, the user spends a long time filling in a form entirely on the browser side (searching employees, drag-and-drop, assigning departments) without hitting the server. Once the session expires, the next request reaches the app already without the `X-Vibe-Authorization` header, and the call to V1 returns `401`, even though the token itself is still valid. For placement apps the extended window (~40 minutes) covers the typical 10–15 minute pauses — a `401` after a short idle no longer appears.

How to handle this:

- **On a `401` from V1** — ask the user to re-open the app from the Bitrix24 menu. Important: this must be a fresh open from the menu (which re-POSTs the placement so the Gateway issues a fresh session), not a return to an already-open tab — re-showing a cached tab is a plain GET with no new POST and won't restore the session. A server-side redirect won't help here: it cannot break out of the iframe. There is no need to refresh the token yourself.
- **For long forms and editors** — save changes incrementally, or keep the session alive with a periodic lightweight request to your own server while the user works (any request to the app passes through the Gateway and extends the session). Note: the browser throttles background tabs, so keepalive is only reliable while the app tab is active.
- **Server-side background work with no user** (cron, integration) is a separate case: `vibe_session_*` is bound to a specific user and is not refreshed server-side without their browser. If you need long-lived server-side access ON BEHALF OF THE SERVER OWNER, use an api-bearer token and its refresh — `POST /v1/infra/servers/:id/access-tokens/:tokenId/refresh` (requires `accessTokensEnabled` turned on by a platform admin). That token acts as the owner, not the current user — do not substitute it for per-user V1 calls.

```js
// On a 401 from V1 the Gateway session expired. A server-side redirect can't leave the
// iframe — return 401 to your SPA and prompt the user to re-open the app from the
// Bitrix24 menu (which re-POSTs the placement and issues a fresh session). Don't refresh the token yourself.
if (vibeResponse.status === 401) {
  return res.status(401).json({ error: 'session_expired', reopen: true })
}
```

## What not to do

- ❌ **`sessionStorage.setItem('vibe_token', …)`** — the token is physically not in the browser, and must not be. OAuth 2.0 Security BCP § 4.1 explicitly forbids storing access tokens in `sessionStorage` / `localStorage`. Do not try to recreate it client-side.
- ❌ **Return the token in the JSON response** of an SPA. `X-Vibe-Authorization` lives exclusively between the Gateway and the app server. Exposing it in the response to the browser destroys the entire advantage of the BFF pattern (XSS → token leak).
- ❌ **Read the `_vibe_gw` cookie by hand**. The cookie is marked `HttpOnly` — JS will not even see it. If you want a session ID for logging, generate your own.
- ❌ **Use `Authorization` instead of `X-Vibe-Authorization`**. The platform prefix was chosen specifically so the app can use its own `Authorization` chain (for example, to proxy to its own upstream service).
- ❌ **Store `vibe_session_*` anywhere beyond a single HTTP session**. It is an access token that is valid for 24 hours and spans the entire V1 API in the app's scope. Any leak is a compromise.
- ❌ **Try to decode `__init` or `_vibe_gw`**. These are HMAC-signed JWTs with a single recipient — the Gateway. The contents are internal and will change in the future.

## A mental model

A useful mental model: **Cloudflare Access / Google IAP / AWS API Gateway**. The proxy (Gateway) does all the authentication and puts the user data into headers on entry. The app reads the headers and trusts them because:

1. A header can only come from the Gateway (everything else is stripped).
2. The tunnel is protected by mTLS / WebSocket at the infrastructure level.
3. If the app needs a capability, it makes a server-side call to the platform API, carrying the token as a Bearer.

The idiomatic read is `req.headers['x-vibe-authorization']` (or your framework's equivalent). The idiomatic response to a 401 from V1 is to redirect the user to the placement, not to refresh the token yourself.

## Incoming requests from outside: webhooks and Bitrix24 events

By default a Black Hole app is private: the Gateway authenticates every request (the chain above). An external call without a platform cookie / Bearer — for example an outbound Bitrix24 webhook or a POST from an event handler — gets `401 BH_LOGIN_REQUIRED`. The response format depends on the request: for `Content-Type: application/json` or a `/v1/…` path it is a JSON error, for a request from the browser it is an HTML login page. So "I configured a webhook to the app's address → I get a 401" is expected behavior of private mode, not a failure.

To accept requests from outside, switch the server to public mode:

```bash
curl -X PATCH https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/access-policy \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "accessPolicy": "PUBLIC" }'
```

In `PUBLIC` mode the Gateway forwards the external request straight to the app with the `X-Vibe-Public: true` header — authentication is optional. Access is open to **everyone** who knows the subdomain address, so validate incoming data on the app side (for Bitrix24 events — the `auth.application_token` field in the request body). If the visitor already has a session, the Gateway additionally forwards the identity headers `X-Vibe-User-*` (and `X-Vibe-Authorization`, when the user has an app token) — just like on the private policies; an anonymous request arrives without them. **To tell whether the visitor is identified, check for `X-Vibe-User-Id`, not `X-Vibe-Authorization`**: the latter carries only the access token for `/v1` and is legitimately empty for guest links and for users without an app token, even when the identity is known. More about the modes — [Access policy](/docs/infra/access/access-policy).

**Timeout — 30 seconds.** The Gateway waits for the app's response for up to 30 seconds. The internal `504` is intercepted by the Gateway itself and does not reach the caller in its original form: an ordinary request gets an HTML application startup or wake page with the status `503`, so you cannot tell the timeout apart by the response body. For long tasks (analysis, generation, import) do not hold the connection open: respond immediately (`202` + a task identifier), do the work in the background, and have the caller poll the status in a separate request. A synchronous POST longer than 30 seconds through a public subdomain will not complete.

**A sleeping server loses the first request.** If the server is SLEEPING, the incoming request wakes it, but the caller at that moment gets an HTML wake page (with status 503), not the app's response — that is, the payload of the first webhook/event does not reach the app. Bitrix24 sends events without a redelivery guarantee. So for reliable event reception, disable auto-sleep (`PATCH /v1/infra/servers/:id/sleep` with the value "never") — otherwise the first event after the server falls asleep will be lost.

### Can a Bitrix24 event (`event.bind`) be pointed at the app's subdomain

Yes. Bitrix24 `event.bind` accepts an arbitrary HTTPS URL as the handler, so the Black Hole subdomain address (`https://app-<hex>.vibecode.bitrix24.com/<path>`) fits — this unlocks an event-driven architecture (event in Bitrix24 → action in the app) without polling every N minutes. Conditions:

1. **HTTPS is mandatory** — Black Hole subdomains are always over HTTPS, the condition is met.
2. **Public mode** — `accessPolicy: PUBLIC` (see above), otherwise the POST from Bitrix24 will hit a `401`.
3. **The server must not sleep** — otherwise the first event will be lost (see above). Disable auto-sleep.
4. **Respond within 30 seconds** — the handler must acknowledge receipt quickly, move heavy processing to the background.
5. `event.bind` is called through Bitrix24's own REST API in the app context. Events are available on commercial Bitrix24 plans. In the request body Bitrix24 sends `auth.application_token` — validate it.

If keeping the server permanently active is not possible, polling on the app side remains more reliable.

## The placement opens with a login screen instead of the app

Symptom: an employee opens the placement tab in Bitrix24, but the iframe shows a login screen, not the app. Or the app lets only the key owner in, while other employees from the access list are still prompted to log in. Two configuration causes.

### The placement handler points at the subdomain, not the platform

Bitrix24 must open the placement through the platform handler `https://vibecode.bitrix24.com/v1/bitrix-handler`, not directly at the app subdomain (`https://app-<hex>.vibecode.bitrix24.com`). Only through the handler does the platform create a `vibe_session_*` session and a one-time code for the Gateway (see "Request lifecycle" above). If the placement is bound directly to the subdomain, Bitrix24 opens the iframe bypassing the platform — no session is created, and the Gateway shows a login screen.

The handler address is set by the platform. You do not need to specify the app subdomain manually. If the placement is already bound to the subdomain, republish the app: republishing re-binds the handler to the platform address.

### The access list applies only under the `NAMED_USERS` policy

In `OWNER_ONLY` mode only the key owner has access — access-list entries added through `POST /access` are not applied. To let in specific employees of the Bitrix24 account, switch the policy to `NAMED_USERS` and add the users:

```bash
curl -X PATCH https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/access-policy \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "accessPolicy": "NAMED_USERS" }'
```

More about the modes and the access list — [Access policy](/docs/infra/access/access-policy).

After both fixes, employees on the access list get into the iframe automatically. A manual login on the subdomain in a separate tab is not required — if you tried this workaround, after fixing the handler it is no longer needed.

## Self-hosted app (not Black Hole)

The `X-Vibe-Authorization` injection works only behind the Gateway, which fronts Black Hole servers. If the app files live on your own server, the header is not injected — the app stores the session token itself (its own BFF) and calls `GET /v1/me` itself.

How to obtain the current user's session token in a placement (iframe):

- **Vibecode app with `appUrl` on your server.** The placement reaches the Vibecode handler, which redirects to `<appUrl>/?code=<one-time-code>&placement=…&member_id=…`. Your server exchanges the code: `POST /v1/oauth/token { app_key, code, redirect_uri }` (the `redirect_uri` value is exactly the configured `appUrl`). Only the one-time code reaches the browser, not the token.
- **Your own Bitrix24 app with its own handler.** The placement reaches your server with the user token (`AUTH_ID`). Exchange it server-to-server: `POST /v1/oauth/placement-session { app_key, access_token, member_id, domain }`.

In both cases the response is `{ access_token: "vibe_session_…", user, expires_in }`. From there the app works as a BFF: it stores `vibe_session_*` on its own side, reads identity via `GET /v1/me` with `Authorization: Bearer <vibe_session_*>`, and never hands the token to the browser. Making your own page embeddable in an iframe — the `X-Frame-Options` and CSP `frame-ancestors` headers — is your responsibility, not the platform's. The standard `GET /v1/oauth/authorize` does not open in an iframe: the Bitrix24 consent page forbids embedding. Open it only in a separate tab.

## See also

- [Black Hole — access and modes](/docs/infra/access)
- [Access tokens](/docs/infra/access-tokens)
- [Deploy API](/docs/infra/deploy)
