# Bitwarden: SSO Authentication Bypass via Silent NVARCHAR(50) Truncation in the SsoUser Lookup
Bitwarden: Signing In As Someone Else Through a 50-Character SQL Parameter
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.
- ➜Vendor: Bitwarden
- ➜Product:
bitwarden/server(SSO / Identity, SQL Server data path) - ➜Severity: High (CVSS 7.5)
- ➜CVE: CVE-XXXX-XXXXX
- ➜Report: HackerOne #3671090
- ➜Fix: PR #7501 — commit `27ae3d54` (PM-35252)
- ➜Fixed in: server release `v2026.5.0` (2026-05-29) — Cloud + self-hosted
//TL;DR
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.)
-- util/Migrator/DbScripts/2025-05-27_00_SsoExternalId.sql
ALTER TABLE [dbo].[SsoUser]
ALTER COLUMN [ExternalId] NVARCHAR(300) NOT NULL
...
CREATE OR ALTER PROCEDURE [dbo].[SsoUser_Create]
...
@ExternalId NVARCHAR(300),
...
CREATE OR ALTER PROCEDURE [dbo].[SsoUser_Update]
...
@ExternalId NVARCHAR(300),
...
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:
-- src/Sql/dbo/Stored Procedures/User_ReadBySsoUserOrganizationIdExternalId.sql
CREATE PROCEDURE [dbo].[User_ReadBySsoUserOrganizationIdExternalId]
@OrganizationId UNIQUEIDENTIFIER,
@ExternalId NVARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
U.*
FROM
[dbo].[UserView] U
INNER JOIN
[dbo].[SsoUser] SU ON SU.[UserId] = U.[Id]
WHERE
(
(@OrganizationId IS NULL AND SU.[OrganizationId] IS NULL)
OR (@OrganizationId IS NOT NULL AND SU.[OrganizationId] = @OrganizationId)
)
AND SU.[ExternalId] = @ExternalId
END
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:
// src/Infrastructure.Dapper/Repositories/UserRepository.cs:101
public async Task<User?> GetBySsoUserAsync(string externalId, Guid? organizationId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<User>(
$"[{Schema}].[{Table}_ReadBySsoUserOrganizationIdExternalId]",
new { OrganizationId = organizationId, ExternalId = externalId },
commandType: CommandType.StoredProcedure);
UnprotectData(results);
return results.SingleOrDefault();
}
}
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:
// src/Infrastructure.EntityFramework/Repositories/UserRepository.cs:160
var ssoUser = await dbContext.SsoUsers.SingleOrDefaultAsync(e =>
e.OrganizationId == organizationId && e.ExternalId == externalId);
Same method name, same arguments, same contract — and one of them answers a question about 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:
// bitwarden_license/src/Sso/Controllers/AccountController.cs:461
var providerUserId = userIdClaim.Value;
var possibleSsoUser = await _userRepository.GetBySsoUserAsync(providerUserId, orgId);
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:
| Identity | ExternalId issued | Stored by SsoUser_Create | Compared by the lookup |
|---|---|---|---|
| Victim | 50 chars | 50 chars | 50 chars |
| Attacker | 66 chars | 66 chars | first 50 — identical to the victim's |
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.
//The exploit
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 theSsoUserrow throughSsoUser_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'ssubverbatim. - 3.
FindUserFromExternalProviderAsyncpulls the claim and callsGetBySsoUserAsync(providerUserId, orgId). - 4.Dapper invokes the procedure. The 66-character argument is coerced into
@ExternalId NVARCHAR(50). TheWHEREclause now reads the victim's identifier. - 5.The procedure returns the victim's
Userrow. The controller signs the caller in as that user. - 6.Identity issues an access token whose
subis the victim's user id.GET /accounts/profilereturns 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.
//Impact
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 about 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.
//The fix
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:
CREATE PROCEDURE [dbo].[User_ReadBySsoUserOrganizationIdExternalId]
@OrganizationId UNIQUEIDENTIFIER,
- @ExternalId NVARCHAR(50)
+ @ExternalId NVARCHAR(300)
AS
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.
//CVE description
Vulnerability Type
CWE-697: Incorrect Comparison — silent SQL parameter truncation in an identity lookup, leading to CWE-287: Improper Authentication.
Description
In Bitwarden Server from v2025.6.0 through v2026.4.2, the stored procedure User_ReadBySsoUserOrganizationIdExternalId declared its @ExternalId parameter as NVARCHAR(50) while the SsoUser.ExternalId column it queries had been widened to NVARCHAR(300). On SQL Server deployments, which reach this procedure through the Dapper data-access layer, any external identifier longer than 50 characters is silently truncated before the WHERE clause evaluates, with no error or warning. An attacker authenticating through an organization's configured identity provider with an identifier whose first 50 characters match another member's complete 50-character identifier is therefore resolved to that member's account, and the server issues an access token scoped to the victim's user id. The Entity Framework implementation of the same repository method performs a full-length comparison and is not affected.
CVSS Score
High, 7.5 — CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H. Attack Complexity is High because the collision needs the victim's stored identifier to be exactly 50 characters, which the attacker cannot arrange. Confidentiality is High for Key Connector deployments, where the access token alone retrieves the user's master key. Integrity and Availability are High because PUT /ciphers/{id} and DELETE /ciphers overwrite and permanently delete vault items on the bearer token alone — no master-password verification and nothing decrypted — so the zero-knowledge model does not mitigate them; scoring Availability strictly as component rather than data availability instead gives 6.8. The vendor's published rating on the report is Medium 5.3, with Integrity and Availability at None.
Affected Components and Patched version
- ➜Component:
src/Sql/dbo/Stored Procedures/User_ReadBySsoUserOrganizationIdExternalId.sql; reached viaUserRepository.GetBySsoUserAsyncinsrc/Infrastructure.Dapper/Repositories/UserRepository.csand invoked frombitwarden_license/src/Sso/Controllers/AccountController.cs. - ➜Affected:
bitwarden/serverv2025.6.0throughv2026.4.2on SQL Server — Bitwarden Cloud and the default self-hosted deployment. Deployments backed by MySQL, PostgreSQL or SQLite use the Entity Framework path and are not affected. - ➜Patched:
v2026.5.0, commit27ae3d5455f723975fac03819eaeba8710666bc4(PR #7501), which reissues the procedure with@ExternalId NVARCHAR(300)and ships migration2026-04-23_00_UpdateReadBySsoUserOrganizationIdExternalI.sql.
Root Cause
An incomplete schema migration. Commit fe0c14e8038e1adecfb945980612387e1059ca85 (PR #5750) widened the SsoUser.ExternalId column and the SsoUser_Create and SsoUser_Update procedures to NVARCHAR(300), but not the read procedure that resolves an SSO identity to a user. SQL Server coerces an oversized argument to a procedure parameter's declared type without raising an error, so the write path persisted full-length identifiers while the read path compared only their first 50 characters — allowing two distinct external identities to resolve to one account.
Attack Vectors
Remote, over the network, through the organization's normal SSO login flow. The attacker needs an account with the organization's identity provider and an external identifier that begins with the victim's complete 50-character identifier. Where the organization keys SSO on a custom claim via additionalUserIdClaimTypes and that claim is user-influenced, the attacker constructs the colliding value directly. No victim interaction, no prior access to the victim's account, no credential, and no race condition is required.
Impact
Authentication as another member of the same organization. The attacker receives an access token carrying the victim's user id, email and name, and holds an authenticated API session as that member: the victim's profile is readable, and the endpoints that modify or permanently delete vault items, folders and Sends operate on ciphertext and require no master password. Under the default SSO-with-master-password configuration, vault contents themselves remain encrypted client-side and cannot be read. Under Key Connector, where the organization has deliberately opted out of the zero-knowledge model and the user has no master password, the access token is sufficient to retrieve the user's master key from the Key Connector service and decrypt the vault. The same defect independently breaks SSO for any user whose identity provider issues an identifier longer than 50 characters, whose lookup can never match their stored record.
Credits: Sanjok Karki (thesanjok)
//Disclosure timeline
All times UTC.
- ➜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; severity set 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.
//Reference
//Takeaways
- ➜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
INSERTand 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 about 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.