A login bug where the password "null" works. The Note Mark OIDC bypass and what it teaches every auth team.
GHSA-pxf8-6wqm-r6hh: Note Mark's local-password endpoint accepted the literal string 'null' as a valid password for users who'd been migrated to OIDC. The hash field was NULL in the database; bcrypt.compare coerced both sides to the string 'null' and returned true. One null check would have prevented it. Walk through the bug, the broader pattern (any app that added SSO to a previously local-auth codebase), and the static + runtime detection rules every team should adopt.
Founder of Valtik Studios. Penetration tester. Based in Connecticut, serving US mid-market.
# A bug where logging in with the password "null" works. The Note Mark OIDC bypass and what it teaches every auth team.
On April 26 2026, GitHub published GHSA-pxf8-6wqm-r6hh: a critical authentication bypass in Note Mark, an open-source note-taking app. The vulnerable code path was extraordinary in its simplicity. If a user account had been migrated to OIDC (single sign-on), and an attacker submitted the password literally null against that account's local-password endpoint, the application logged them in.
This is the kind of bug that sounds made up. It is the kind of bug that gets named, mocked, and added to the canonical list of authentication failures. It is also the kind of bug that, if you spend ten minutes thinking about how it happened, makes you nervous about your own auth code.
What the bug actually looks like
Note Mark supports two authentication paths: local username/password, and OIDC for users whose admin migrated them to SSO. The intended flow for an OIDC-only user is that the local-password endpoint returns 401 unconditionally because the user's password hash field is NULL in the database.
The vulnerable code path roughly looked like:
const user = await db.user.findUnique({ where: { username } });
if (!user) return unauthorized();
const ok = await bcrypt.compare(submittedPassword, user.password_hash);
if (!ok) return unauthorized();
return loginSession(user);
For an OIDC-migrated user, user.password_hash is NULL. The naive expectation is that bcrypt.compare(anything, null) returns false and the code path bails. The actual bcrypt behavior, depending on the library binding, is:
- Some libraries throw on null hash (safe).
- Some libraries return
falseon null hash (safe). - A few libraries coerce null to a string (
"null"or"") and then compare. If the submitted password is also coerced to a string and equals the coerced hash, the comparison returns true.
The bypass works when the attacker submits the literal string null as the password, the Node binding coerces both sides to the string "null", and bcrypt.compare("null", "null") returns true.
The fix is one line: explicitly check that the password hash is not null before calling compare.
if (user.password_hash == null) return unauthorized();
const ok = await bcrypt.compare(submittedPassword, user.password_hash);
That is it. One null-check.
Why this happens in practice
The bug is funny, but the pattern that produces it is not unique to Note Mark. It happens any time the codebase has two authentication backends that share a single login endpoint, and one of those backends populates a field that the other relies on.
The places this pattern shows up most:
- Apps that added SSO after launching with local auth. The local-auth code path was written assuming
password_hashis always populated. SSO migration introducedNULLrows. The local-auth path never got an explicit guard.
- Apps using ORM nullable types where the original code was written for non-null. Prisma, Drizzle, TypeORM all happily return
nullfor nullable columns. The code calling them was written when the field was non-nullable.
- Apps where authentication logic spans 4+ functions. The "is this user OIDC-migrated?" check lives in one function. The "does this password match?" check lives in another. There is no single function that says "given username, verify password" with all guards in scope.
- Apps with type-coercing comparison libraries. bcrypt is the example here, but the same applies to JWT-verify functions that accept null secrets, HMAC compare functions that don't validate input lengths, and signature-verify functions that fall back to "always true" on malformed inputs.
What this is not
This bug is not about NoSQL injection. The infamous {"$ne": null} MongoDB injection that lets you log in by sending a JSON object instead of a string is a different bug class. That one is a query injection. The Note Mark bug is a string-coercion bug in the password-comparison path.
It is also not about prototype pollution. A pollution-based bypass tampers with Object.prototype to make user.password_hash evaluate to a controlled value. The Note Mark bug works on a clean process.
It is the simplest possible class: nullable field, no guard, comparison library that doesn't reject null, attacker-controlled input that coerces to the same value the comparison library coerces null to.
The general lesson
There is a defensive-coding rule that catches this whole bug class:
If a field can be null, the code that reads it must explicitly handle null before passing it to any comparison, hash, or signature-verify function.
Stronger version: never call compare(input, stored) without first asserting stored is not null and is the right type.
Stronger still: make your comparison helper a single function that takes a username and a submitted password, looks up the user, asserts the user exists, asserts the user has a local-password backend, asserts the password hash is non-null, calls bcrypt.compare, and returns a single Boolean. No call site outside that helper should be calling bcrypt.compare directly.
Where to check this in your own code
Pull the codebase. Run these searches and read every result:
# Direct bcrypt usage, every call site needs a null guard
rg -n 'bcrypt\.(compare|compareSync)\(' --type=ts --type=js --type=py
# Argon2 / scrypt / pbkdf2, same pattern
rg -n '(argon2|scrypt|pbkdf2)\.\w*verify' --type=ts --type=js
# Direct password-hash field reads
rg -n '\.password_hash|\.password\b' --type=ts --type=js | grep -v test
# OIDC migration helpers, the field that flips users to SSO
rg -n 'sso_only|oidc_only|sso_migrated|external_auth' --type=ts --type=js
For each result, check:
- Is the password-hash field nullable in the schema?
- Is there an explicit null check before compare?
- Is the user table joined or filtered to exclude OIDC-only users in this code path?
- Is there a unit test that submits the literal string
nullas the password and asserts a 401?
If you don't have that last test, you have the same bug class as Note Mark, even if your specific bcrypt binding doesn't coerce. Add the test.
Auth backends with this pattern in their history
This is not an exhaustive list, but the following auth implementations have all shipped a variant of "null password matches null hash" at some point in the last decade:
- Internal IAM systems at multiple Fortune 500 companies (we have seen this in two engagements).
- WordPress plugins doing SSO migration without removing the local-auth code path.
- Rails apps with
has_secure_passwordplus a custom OmniAuth path where the user record'spassword_digestis nullable. - Express + Passport.js apps with a local strategy that compares a session-attached
user.passwordHashwithout checking the field's existence.
Every one of these had the same root cause as Note Mark. None of them looked like a bug at code-review time. The dangerous interaction was always at the boundary between an SSO migration and a local-auth code path written before SSO existed.
Detection
If you operate the application at runtime, the best detection is a log alert on any login that succeeds for a user whose is_sso or oidc_migrated flag is true. That should never happen on the local-password endpoint. If it does, you have a live exploit.
If you only have access to source, the static check is "every call to bcrypt.compare or equivalent must be preceded within the same function by an explicit null/undefined check on the hash argument." Static analyzers can be configured to enforce this.
How Valtik can help
Note-Mark-class bugs almost always survive code review because they look correct. They get caught in penetration testing only when the tester explicitly tries to log in with null, an empty string, a JSON null literal, and a few other edge inputs. We do this in every web-app and SaaS engagement.
If your app added SSO to a previously local-auth codebase, that integration is exactly the seam this bug class lives in. Reach out at tre@valtikstudios.com for a focused auth review or a full-stack penetration test.
---
References:
- GHSA-pxf8-6wqm-r6hh, Note Mark: OIDC-registered users authenticated by submitting password "null". GitHub Security Advisories, 2026-04-26.
- RFC 6749 (OAuth 2.0), Authorization framework.
- OWASP Authentication Cheat Sheet, Password storage and comparison guidance.
Want us to check your Application setup?
Our scanner detects this exact misconfiguration. plus dozens more across 38 platforms. Free website check available, no commitment required.
