Skip to content
Bitwarden Sso Externalid Truncation

Bitwarden Sso Externalid Truncation

sanjokkarki.com.np • September 29, 2026

The column was widened to 300. The lookup that reads it stayed at 50. SQL Server truncated the difference without a word.

Another writeup in my Bitwarden series. This one is not an authorization bug — every check on the SSO login path was present and correct. They just ran on an identifier that had been quietly cut short before the comparison, because a stored procedure parameter was left behind by an earlier schema migration.

➜ Product: bitwarden/server (SSO / Identity, SQL Server data path)

➜ Severity: High (CVSS 7.5 — my scoring; Bitwarden published Medium 5.3)

➜ CVE: CVE-2026-101878

➜ Report: HackerOne #3671090

➜ Fix: PR #7501 — commit `27ae3d54` (PM-35252)

➜ Fixed in: server release `v2026.5.0` (2026-05-29) — Cloud + self-hosted

Bitwarden links an SSO identity to a Bitwarden account through SsoUser.ExternalId — whatever unique identifier the organization's IdP issues for that person. In May 2025 that column was widened from NVARCHAR(50) to NVARCHAR(300) to accommodate longer identifiers. The migration widened the column and both write procedures. It missed the read procedure.

So the write path stored up to 300 characters, and the lookup path compared against a parameter declared at 50. SQL Server does not raise an error when a value overflows a stored-procedure parameter — it silently keeps the first 50 characters and executes the query. An attacker whose IdP-issued identifier began with the victim's complete 50-character identifier was looked up as the victim, and Bitwarden issued them an access token carrying the victim's user id, email and name.

Reproduced live against Bitwarden Cloud with an Enterprise organization, OIDC SSO, and a mock IdP under my control on both sides of the boundary.

// The vulnerable code

The widening landed on 2025-05-27 in PR #5750 , commit `fe0c14e8` , shipped in v2025.6.0 . It is careful work — the column, both write procedures, and the [MaxLength(300)] attribute on the entity in src/Core/Auth/Entities/SsoUser.cs all moved to 300 together. (Listings here elide unrelated statements with ... and are de-indented from their enclosing class; every other line is verbatim.)

Four of the five places ExternalId is declared. The fifth — the one that reads it back at every login — kept its original width for another eleven months:

That = is the entire authentication decision for an SSO login, and it compares a full-length stored value against a parameter that cannot hold one. Overflow an INSERT target and SQL Server raises error 8152 and aborts; overflow a procedure parameter and it does neither — the value is coerced to the declared type on assignment, the surplus characters are dropped, and the body runs against what is left. No error, no warning, nothing in a log. The client sends 66 characters; the WHERE clause evaluates 50.

Only one of the two data-access layers reaches that procedure. Dapper , used wherever the backing store is SQL Server, calls it by name and inherits the parameter's width:

Entity Framework , used for MySQL, PostgreSQL and SQLite, never touches the procedure. It builds the predicate from the entity, so the comparison is full-length:

Same method name, same arguments, same contract — and one of them answers a question the first 50 characters. Bitwarden Cloud runs SQL Server, and so does the default self-hosted deployment.

The SSO controller is the call site, where an attacker-influenced value meets the lookup:

userIdClaim resolves in order: any claim type the organization configured in additionalUserIdClaimTypes , then sub , then a non-transient NameIdentifier , then uid , upn , eppn . Whichever one wins becomes the primary key of the authentication decision — which gives the shape an attacker needs:

Both rows are written correctly — SsoUser_Create takes 300 characters, so the attacker's full 66 land intact. The divergence is only on read. And truncating anything longer than 50 characters yields exactly 50, so the victim's stored identifier has to be exactly 50 characters for the equality to hold; a 36-character Entra ID object id cannot be hit this way. (SQL Server's = ignores trailing blanks, so a shorter victim value would also compare equal to itself padded out to the boundary — but that needs an IdP willing to emit a claim with trailing whitespace, which I did not test.)

Where it stops being a coincidence is additionalUserIdClaimTypes . An organization that keys SSO on a custom claim — employee number, department id, anything the directory lets a user populate — hands the attacker direct control of the left-hand side. Then the collision isn't found, it's typed: the victim's 50 characters, then any suffix at all.

An Enterprise organization on Bitwarden Cloud with OIDC SSO pointed at a mock IdP I controlled, exposed over an HTTPS tunnel. Two identities, both mine.

1 . Victim signs in. The IdP issues sub = a 50-character string. Bitwarden provisions the account and writes the SsoUser row through SsoUser_Create , which accepts all 50.

2 . Attacker signs in through the same SSO configuration. The IdP issues sub = a 66-character string whose first 50 characters are the victim's sub verbatim.

3 . FindUserFromExternalProviderAsync pulls the claim and calls GetBySsoUserAsync(providerUserId, orgId) .

4 . Dapper invokes the procedure. The 66-character argument is coerced into @ExternalId NVARCHAR(50) . The WHERE clause now reads the victim's identifier.

5 . The procedure returns the victim's User row. The controller signs the caller in as that user.

6 . Identity issues an access token whose sub is the victim's user id. GET /accounts/profile returns the victim's profile.

Two scripts automate it end to end — a mock OIDC provider that serves discovery, JWKS and both identities, and a driver that walks the full identity.bitwarden.com → sso.bitwarden.com → IdP → back redirect chain with PKCE and form_post , then decodes both access tokens and prints the user ids side by side. The attacker's token carries the victim's.

What isolates the cause is that the two identities differ in nothing but length. Same organization, same SSO configuration, same claim type, same flow — one identifier fits the parameter and provisions its own account, the other overflows it and lands on the victim's.

What the bypass delivers is an authenticated session as another member of the organization. What that session is worth depends on how the organization gets its keys — and Bitwarden ships three answers.

SSO + master password. The vault syncs, but every cipher comes back encrypted under a key derived client-side from a password the attacker does not have. Confidentiality is limited to what the server holds in cleartext: user id, email, name, organization membership. The bearer token still reaches the write and delete endpoints, which operate on ciphertext and never ask for the master password — so integrity and availability are exposed even where confidentiality is not.

SSO + Key Connector. There is no master password. The token response carries a KeyConnectorOption with the service URL, and the client fetches the user's master key from {keyConnectorUrl}/user-keys authorized by nothing but the Bitwarden access token. The bypass hands the attacker that token. Key Connector is Bitwarden's documented, deliberate exception to the zero-knowledge model, and in that configuration authentication is decryption.

SSO + Trusted Device Encryption. Also no master password; vault keys arrive through device trust, either from an already-trusted device or through an administrator approving a new one. An attacker holding the victim's session can request approval for a device of their own, and approving it is a routine helpdesk action.

I want to be exact what I demonstrated, because the distinction matters: the PoC proves the bypass at the token level — the attacker's access token carries the victim's identity, and the victim's profile is readable. It does not proceed to cipher decryption. The Key Connector and TDE paths are read from the shipped code and Bitwarden's own documentation, not exercised.

The quieter half is that this breaks correctness for everyone, attacker or not. Any user whose IdP issues an identifier longer than 50 characters — LDAP distinguished names, most obviously — fails their own lookup on every login, because the truncated key can never match their stored full-length row. Bitwarden then treats a returning user as a new one.

PR #7501 , commit `27ae3d54` , merged 2026-04-29 and shipped in v2026.5.0 . One parameter declaration, plus the migration that applies it to databases already in the field:

util/Migrator/DbScripts/2026-04-23_00_UpdateReadBySsoUserOrganizationIdExternalI.sql reissues the procedure at the corrected width. Nothing else changed — no logic, no new check. There was never anything wrong with the logic.

// Disclosure timeline

➜ 2026-04-13 — Reported to HackerOne (#3671090) with source analysis and a live end-to-end PoC against Bitwarden Cloud.

➜ 2026-04-16 — Triaged and confirmed; Bitwarden set severity at Medium 5.3.

➜ 2026-04-29 — Fix merged to main : PR #7501 , commit `27ae3d54` (PM-35252).

➜ 2026-05-29 — Shipped in server release `v2026.5.0` ; report resolved and bounty awarded .

➜ 2026-05-29 — Public writeup.

➜ A migration's blast radius is every declaration of the field, not every writer of it. This one updated the column, the entity and both write procedures — and it is a good migration, which is the point. The read procedure sat in a different directory and was never in view. Widening a column is not one change; it is one change per place the width is restated.

➜ Silent coercion is worse than a hard failure. Overflow an INSERT and SQL Server stops you. Overflow a procedure parameter and it quietly answers a narrower question than the one you asked. Every type declaration between the wire and the predicate is part of the comparison, and the ones that fail loudly are the ones you never have to find.

➜ Two implementations of one repository are two security boundaries. The Dapper and EF paths here have identical signatures, identical call sites and different answers. Reading one and assuming the other is how a whole class of bug survives review — so when a product ships parallel data layers, an audit that stops at the first one has covered half the product.

➜ An identifier is a security decision. Nothing in this bug is an authorization mistake; the membership checks, the organization scoping and the token issuance are all correct. They were just correct the wrong person. When a lookup key is the thing that proves who you are, its length, encoding and comparison semantics are authentication logic.

Thanks to @mandreko-bitwarden and the Bitwarden security team for the triage and the fix.