diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9227c66e..742ab0df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **MCP**: the HTTP bind-safety guard now keys on auth posture, not bind interface. The old `_assert_safe_http_bind` refused any non-loopback HTTP bind, which false-positived on the entire hosted deployment (a container binds `0.0.0.0` and is still private) and lived in the run path, so a caller building the ASGI app directly bypassed it. It is replaced by `McpSettings._enforce_bind_safety`, a cross-field validator at the settings boundary that every serving path inherits: the unauthenticated `local` profile still refuses a non-loopback HTTP bind unless `PIPEFY_MCP_ALLOW_INSECURE_HTTP_BIND` is set, while the `remote` profile (which always carries inbound auth) binds any host without a bind-host check. Loopback detection moves to `pipefy_infra.security.is_loopback_host`, covering all of `127.0.0.0/8` and `::1` rather than three hardcoded string literals. Closes #379.
- **CLI / Plugin (Claude Code)**: the CLI install path now installs `pipefy-cli` from PyPI instead of a `git+…#subdirectory=packages/cli` reference with `--with` sibling pins. The `/pipefy:install` slash command runs `uv tool install --force pipefy-cli`, and the documented snippets (root `README.md`, `packages/cli/README.md`, `docs/MIGRATION.md`) use `uvx --from pipefy-cli pipefy …` / `uv tool install pipefy-cli`. PyPI resolves `pipefy` and `pipefy-auth` transitively, so the explicit `--with` flags are gone and installs no longer pay a git clone plus build. This matches the MCP server's PyPI install (#368); do not pass a global `--prerelease allow`, which would let transitive dependencies jump to their own pre-releases. `RELEASE.md` verification snippets move to PyPI accordingly, and the now-unused `latest` moving-tag release step is removed. Closes #234.
+### Fixed
+
+- **Auth / CLI / MCP (Windows)**: `pipefy auth login` on Windows failed at the session-store step with `WinError 1783` ("The stub received bad data") from `CredWrite`. The stored session — access + refresh + id tokens plus metadata, UTF-16 encoded by `keyring` — exceeds Windows Credential Manager's `CRED_MAX_CREDENTIAL_BLOB_SIZE` (2560 bytes; the exact size varies with token claims), so the default `auto` backend can't store it. Added `PIPEFY_KEYCHAIN_BACKEND=dpapi` (`keychain_backend = "dpapi"` in TOML) — a DPAPI-encrypted file keyring (`keyrings.alt.Windows.EncryptedKeyring`) that is encrypted at rest and has no blob cap. Read from `config.toml` by both the CLI and MCP server, so a single per-user entry covers both processes. Opt-in; selecting `dpapi` off-Windows raises. The plaintext `file` backend is unchanged.
+
## [0.3.0-alpha.1] - 2026-07-04
### Added
diff --git a/docs/cli/auth.md b/docs/cli/auth.md
index d5380c7c..454d2e6f 100644
--- a/docs/cli/auth.md
+++ b/docs/cli/auth.md
@@ -91,7 +91,7 @@ PIPEFY_TOKEN="$MY_BEARER" uv run pipefy pipe list
| `PIPEFY_SERVICE_ACCOUNT_CLIENT_SECRET` | Tier 3 | Service-account client secret. |
| `PIPEFY_AUTH_CLIENT_ID` | Tier 4 | Public client id registered for the CLI. Defaults to `pipefy-cli`. |
| `PIPEFY_DISABLE_STORED_SESSION` | Tier 4 | When `1` / `true`, the stored-session tier is skipped end-to-end: tier resolution never probes the keychain, and `pipefy auth login` / `pipefy auth logout` refuse with exit code 2. Use to avoid the keyring backend-discovery cost on cold start (headless Linux, CI) or to opt out of OS-keychain storage entirely. TOML key: `disable_stored_session`. |
-| `PIPEFY_KEYCHAIN_BACKEND` | Tier 4 | Active `keyring` backend. `auto` (default) uses OS-keyring discovery; `file` swaps to a plaintext on-disk keyring under `~/.config/pipefy/keyring.cfg` (POSIX) / `%APPDATA%/pipefy/keyring.cfg` (Windows). TOML key: `keychain_backend`. |
+| `PIPEFY_KEYCHAIN_BACKEND` | Tier 4 | Active `keyring` backend. `auto` (default) uses OS-keyring discovery; `file` swaps to a plaintext on-disk keyring under `~/.config/pipefy/keyring.cfg` (POSIX) / `%APPDATA%/pipefy/keyring.cfg` (Windows); `dpapi` (Windows only) uses a DPAPI-encrypted keyring that sidesteps the Credential Manager blob cap. TOML key: `keychain_backend`. |
The service-account OAuth token URL (Tier 3) and the OIDC issuer URL (Tier 4) are **not** interchangeable: the first is `/oauth/token` for client-credentials, the second is the full OIDC discovery root for the user-login flow.
@@ -216,7 +216,9 @@ Every `PIPEFY_*` env var is validated against a semantically meaningful regex at
### `Login succeeded but the session could not be stored in your keychain ()`
-The login worked but `keyring` couldn't write the entry. On macOS / Windows this is rare. On headless Linux it usually means no Secret Service daemon is running — install `gnome-keyring` or `kwallet`, set `PIPEFY_KEYCHAIN_BACKEND=file` to use a plaintext file backend under the Pipefy config directory, or fall back to a static `PIPEFY_TOKEN`.
+The login worked but `keyring` couldn't write the entry. On headless Linux it usually means no Secret Service daemon is running — install `gnome-keyring` or `kwallet`, set `PIPEFY_KEYCHAIN_BACKEND=file` to use a plaintext file backend under the Pipefy config directory, or fall back to a static `PIPEFY_TOKEN`.
+
+On **Windows** the failure is `WinError 1783` ("The stub received bad data") from `CredWrite`: the stored session (access + refresh + id tokens plus metadata, UTF-16 encoded) exceeds Windows Credential Manager's `CRED_MAX_CREDENTIAL_BLOB_SIZE` (2560 bytes) — the exact size varies with the token claims — so the default `auto` backend can't store it. Set `PIPEFY_KEYCHAIN_BACKEND=dpapi` (`keychain_backend = "dpapi"` in TOML) for a DPAPI-encrypted keyring with no size cap.
When `PIPEFY_KEYCHAIN_BACKEND=file` is active the backend reports as `PlaintextKeyring` and the hint switches to a config-directory writability check (the file backend writes to `keyring.cfg` under the resolved config directory).
@@ -269,4 +271,6 @@ Two env vars (mirrored as TOML keys) override the default behaviour:
- `PIPEFY_KEYCHAIN_BACKEND=file` swaps the active backend to a plaintext on-disk keyring under `config_dir() / "keyring.cfg"` (`~/.config/pipefy/keyring.cfg` on POSIX, `%APPDATA%/pipefy/keyring.cfg` on Windows). Unblocks headless Linux without Secret Service. **The file is plaintext, not OS-secured**: anyone with read access to the file (including a co-tenant on a shared CI runner) reads the refresh token. Opt-in only.
-These are independent: `PIPEFY_DISABLE_STORED_SESSION=1` takes precedence (the file backend is never read or written). `pipefy auth status` will reflect the active backend name regardless (`Keyring`, `PlaintextKeyring`, etc.).
+- `PIPEFY_KEYCHAIN_BACKEND=dpapi` (**Windows only**) swaps to `keyrings.alt.Windows.EncryptedKeyring`, a DPAPI-encrypted file keyring. Unlike `file` it is encrypted at rest (per-user, via Windows DPAPI), and unlike the default Credential Manager backend it has no ~2560-byte blob cap — so it is the recommended fix when `pipefy auth login` fails with `WinError 1783`. Selecting it on a non-Windows platform raises.
+
+These are independent: `PIPEFY_DISABLE_STORED_SESSION=1` takes precedence (the file backend is never read or written). `pipefy auth status` will reflect the active backend name regardless (`Keyring`, `PlaintextKeyring`, `EncryptedKeyring`, etc.).
diff --git a/docs/config.md b/docs/config.md
index 67a5a4c6..d8981731 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -79,7 +79,7 @@ Credential variables reject leading and trailing whitespace; `PIPEFY_ORG_ID` (be
| `PIPEFY_ORG_ID` | unset | Convenience: pins a default org for CLI and MCP tools that take an optional `org_id` argument. |
| `PIPEFY_PORTAL_ORG_UUID` | unset | SDK portal integration tests only (`pytest -m integration -k portal`). Set in local `.env` to an organization **UUID** (or numeric org id string) where the active token has **`manage_portals`** (and usually `create_portal`). Never committed; runtime MCP/CLI do not read this. Many default orgs return `PERMISSION_DENIED` on portal writes — pick an org with portal admin scope. See [`mcp/tools/portal.md`](mcp/tools/portal.md#testing). |
| `PIPEFY_DISABLE_STORED_SESSION` | `0` | Set to `1` (or `disable_stored_session = true` in TOML) to skip the keychain-backed stored-session tier entirely. `pipefy auth login` / `auth logout` refuse with exit code 2 when set. |
-| `PIPEFY_KEYCHAIN_BACKEND` | `auto` | Set to `file` (or `keychain_backend = "file"` in TOML) to use a file-backed plaintext keyring under `~/.config/pipefy/keyring.cfg` (`%APPDATA%\pipefy\keyring.cfg` on Windows). Unblocks headless Linux and CI runners. Plaintext on disk; opt-in only. |
+| `PIPEFY_KEYCHAIN_BACKEND` | `auto` | Set to `file` (or `keychain_backend = "file"` in TOML) to use a file-backed plaintext keyring under `~/.config/pipefy/keyring.cfg` (`%APPDATA%\pipefy\keyring.cfg` on Windows). Unblocks headless Linux and CI runners. Plaintext on disk; opt-in only. On Windows, set to `dpapi` (`keychain_backend = "dpapi"`) for a DPAPI-encrypted keyring that sidesteps the Credential Manager blob cap (2560 bytes) the Pipefy session exceeds — the default `auto` backend fails such a login with `WinError 1783`. Selecting `dpapi` off-Windows raises. |
| `PIPEFY_ALLOW_INSECURE_URLS` | `false` | Disables the SSRF host check on URL variables. Local development only. |
| `PIPEFY_CONFIG_FILE` | unset | Overrides the default `config.toml` path. See [File path](#file-path) above. |
diff --git a/packages/auth/src/pipefy_auth/settings.py b/packages/auth/src/pipefy_auth/settings.py
index 97b2071a..9ddd7f56 100644
--- a/packages/auth/src/pipefy_auth/settings.py
+++ b/packages/auth/src/pipefy_auth/settings.py
@@ -191,8 +191,8 @@ def _strip_str(cls, value: object) -> object:
@field_validator("keychain_backend", mode="before")
@classmethod
def _normalize_keychain_backend(cls, value: object) -> object:
- # ``keychain_backend`` is ``Literal["auto", "file"]``; copy-pasted env
- # values like ``PIPEFY_KEYCHAIN_BACKEND=' AUTO '`` should normalize to
+ # ``keychain_backend`` is ``Literal["auto", "file", "dpapi"]``; copy-pasted
+ # env values like ``PIPEFY_KEYCHAIN_BACKEND=' AUTO '`` should normalize to
# ``"auto"`` rather than fail Literal validation with a cryptic enum
# error. Case is meaningful for credential fields (kept strict via
# ``_strip_str``), so the lowering applies only here.
@@ -301,7 +301,7 @@ def _normalize_keychain_backend(cls, value: object) -> object:
),
)
- keychain_backend: Literal["auto", "file"] = Field(
+ keychain_backend: Literal["auto", "file", "dpapi"] = Field(
default="auto",
description=(
"Active ``keyring`` backend (env: PIPEFY_KEYCHAIN_BACKEND). ``auto`` "
@@ -309,7 +309,10 @@ def _normalize_keychain_backend(cls, value: object) -> object:
"``keyrings.alt.file.PlaintextKeyring`` writing under "
"``config_dir()/keyring.cfg``. The file backend stores credentials "
"in plaintext on disk; opt-in only, intended for headless Linux "
- "without Secret Service or for CI runners."
+ "without Secret Service or for CI runners. ``dpapi`` (Windows only) "
+ "swaps to a DPAPI-encrypted file keyring, sidestepping the Windows "
+ "Credential Manager blob cap (2560 bytes) that the Pipefy session "
+ "exceeds; selecting it off-Windows raises."
),
)
diff --git a/packages/auth/src/pipefy_auth/storage.py b/packages/auth/src/pipefy_auth/storage.py
index a4959437..f465878c 100644
--- a/packages/auth/src/pipefy_auth/storage.py
+++ b/packages/auth/src/pipefy_auth/storage.py
@@ -12,6 +12,7 @@
from __future__ import annotations
+import sys
import time
from typing import Annotated, Any, Literal
from urllib.parse import urlparse
@@ -35,7 +36,7 @@
_TOKEN_FIELDS: frozenset[str] = frozenset(TokenResponse.model_fields.keys())
-def configure_keychain_backend(choice: Literal["auto", "file"]) -> None:
+def configure_keychain_backend(choice: Literal["auto", "file", "dpapi"]) -> None:
"""Apply the requested keyring backend before any session read or write.
Idempotent: safe to call multiple times; the second ``"file"`` call
@@ -48,10 +49,33 @@ def configure_keychain_backend(choice: Literal["auto", "file"]) -> None:
``pipefy_infra.config.config_dir() / "keyring.cfg"``; the file stores
credentials in plaintext on disk and is intended for headless
Linux or CI runners where the OS keychain is unavailable.
+ ``"dpapi"`` (Windows only) swaps to
+ :class:`keyrings.alt.Windows.EncryptedKeyring`, a DPAPI-encrypted
+ file keyring. It sidesteps the Windows Credential Manager
+ ``CredWrite`` blob cap (``CRED_MAX_CREDENTIAL_BLOB_SIZE`` = 2560
+ bytes). The session blob (access + refresh + id tokens plus
+ metadata, UTF-16 encoded) exceeds that in practice — its exact
+ size varies with the token claims — so the ``auto`` backend fails
+ the write with ``WinError 1783``.
+
+ Raises:
+ RuntimeError: When ``"dpapi"`` is selected on a non-Windows platform.
"""
if choice == "auto":
return
import keyring
+
+ if choice == "dpapi":
+ if sys.platform != "win32":
+ raise RuntimeError(
+ "keychain_backend='dpapi' is Windows-only (DPAPI via CRYPT32.DLL); "
+ "use 'auto' or 'file' on this platform."
+ )
+ from keyrings.alt.Windows import EncryptedKeyring
+
+ keyring.set_keyring(EncryptedKeyring())
+ return
+
from keyrings.alt.file import PlaintextKeyring
from pipefy_infra.config import config_dir
diff --git a/packages/auth/tests/test_storage_backend.py b/packages/auth/tests/test_storage_backend.py
index 2fa16027..a1461c70 100644
--- a/packages/auth/tests/test_storage_backend.py
+++ b/packages/auth/tests/test_storage_backend.py
@@ -5,6 +5,8 @@
from __future__ import annotations
import json
+import sys
+import types
from collections.abc import Iterator
from pathlib import Path
@@ -82,6 +84,36 @@ def test_file_backend_is_idempotent(
assert first.file_path == second.file_path
+@pytest.mark.unit
+def test_dpapi_backend_rejected_off_windows(
+ _isolated_keyring: None,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """``dpapi`` is Windows-only; selecting it elsewhere fails fast with guidance."""
+ monkeypatch.setattr("pipefy_auth.storage.sys.platform", "linux")
+ with pytest.raises(RuntimeError, match="dpapi.*Windows"):
+ configure_keychain_backend("dpapi")
+
+
+@pytest.mark.unit
+def test_dpapi_backend_installs_encrypted_keyring_on_windows(
+ _isolated_keyring: None,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """On Windows, ``dpapi`` installs the DPAPI-encrypted keyring.
+
+ ``keyrings.alt.Windows`` can't be imported off-Windows (it binds CRYPT32.DLL),
+ so stub the module to exercise the branch on any CI platform.
+ """
+ monkeypatch.setattr("pipefy_auth.storage.sys.platform", "win32")
+ fake_module = types.ModuleType("keyrings.alt.Windows")
+ fake_module.EncryptedKeyring = InMemoryKeyring # type: ignore[attr-defined]
+ monkeypatch.setitem(sys.modules, "keyrings.alt.Windows", fake_module)
+
+ configure_keychain_backend("dpapi")
+ assert isinstance(keyring.get_keyring(), InMemoryKeyring)
+
+
@pytest.mark.unit
def test_file_backend_round_trip_writes_under_config_dir(
_isolated_keyring: None,
diff --git a/packages/cli/src/pipefy_cli/commands/auth.py b/packages/cli/src/pipefy_cli/commands/auth.py
index 8dccf341..3603b766 100644
--- a/packages/cli/src/pipefy_cli/commands/auth.py
+++ b/packages/cli/src/pipefy_cli/commands/auth.py
@@ -203,9 +203,12 @@ def _open(url: str) -> bool:
else:
hint = (
"On headless Linux, ensure a Secret Service daemon "
- "(gnome-keyring, kwallet) is running, set "
- "PIPEFY_KEYCHAIN_BACKEND=file to use a plaintext file backend, "
- "or use a static PIPEFY_TOKEN."
+ "(gnome-keyring, kwallet) is running, or set "
+ "PIPEFY_KEYCHAIN_BACKEND=file for a plaintext file backend. "
+ "On Windows, if the session is too large for Credential Manager "
+ "(WinError 1783), set PIPEFY_KEYCHAIN_BACKEND=dpapi for a "
+ "DPAPI-encrypted keyring with no size cap. "
+ "Or use a static PIPEFY_TOKEN."
)
typer.echo(
f"Login succeeded but the session could not be stored in your "