VYPR
Low severityNVD Advisory· Published Mar 24, 2026· Updated Mar 25, 2026

Parse Server: MFA recovery code single-use bypass via concurrent requests

CVE-2026-33624

Description

Parse Server is an open source backend that can be deployed to any infrastructure that can run Node.js. Prior to versions 8.6.60 and 9.6.0-alpha.54, an attacker who obtains a user's password and a single MFA recovery code can reuse that recovery code an unlimited number of times by sending concurrent login requests. This defeats the single-use design of recovery codes. The attack requires the user's password, a valid recovery code, and the ability to send concurrent requests within milliseconds. This issue has been patched in versions 8.6.60 and 9.6.0-alpha.54.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
parse-servernpm
>= 9.0.0, < 9.6.0-alpha.549.6.0-alpha.54
parse-servernpm
< 8.6.608.6.60

Affected products

1

Patches

2
5e70094250a3

fix: MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) (#10275)

4 files changed · +115 9
  • spec/vulnerabilities.spec.js+75 0 modified
    @@ -4240,6 +4240,81 @@ describe('(GHSA-g4cf-xj29-wqqr) DoS via unindexed database query for unconfigure
       });
     });
     
    +describe('(GHSA-2299-ghjr-6vjp) MFA recovery code reuse via concurrent requests', () => {
    +  const mfaHeaders = {
    +    'X-Parse-Application-Id': 'test',
    +    'X-Parse-REST-API-Key': 'rest',
    +    'Content-Type': 'application/json',
    +  };
    +
    +  beforeEach(async () => {
    +    await reconfigureServer({
    +      auth: {
    +        mfa: {
    +          enabled: true,
    +          options: ['TOTP'],
    +          algorithm: 'SHA1',
    +          digits: 6,
    +          period: 30,
    +        },
    +      },
    +    });
    +  });
    +
    +  it('rejects concurrent logins using the same MFA recovery code', async () => {
    +    const OTPAuth = require('otpauth');
    +    const user = await Parse.User.signUp('mfauser', 'password123');
    +    const secret = new OTPAuth.Secret();
    +    const totp = new OTPAuth.TOTP({
    +      algorithm: 'SHA1',
    +      digits: 6,
    +      period: 30,
    +      secret,
    +    });
    +    const token = totp.generate();
    +    await user.save(
    +      { authData: { mfa: { secret: secret.base32, token } } },
    +      { sessionToken: user.getSessionToken() }
    +    );
    +
    +    // Get recovery codes from stored auth data
    +    await user.fetch({ useMasterKey: true });
    +    const recoveryCode = user.get('authData').mfa.recovery[0];
    +    expect(recoveryCode).toBeDefined();
    +
    +    // Send concurrent login requests with the same recovery code
    +    const loginWithRecovery = () =>
    +      request({
    +        method: 'POST',
    +        url: 'http://localhost:8378/1/login',
    +        headers: mfaHeaders,
    +        body: JSON.stringify({
    +          username: 'mfauser',
    +          password: 'password123',
    +          authData: {
    +            mfa: {
    +              token: recoveryCode,
    +            },
    +          },
    +        }),
    +      });
    +
    +    const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery()));
    +
    +    const succeeded = results.filter(r => r.status === 'fulfilled');
    +    const failed = results.filter(r => r.status === 'rejected');
    +
    +    // Exactly one request should succeed; all others should fail
    +    expect(succeeded.length).toBe(1);
    +    expect(failed.length).toBe(9);
    +
    +    // Verify the recovery code has been consumed
    +    await user.fetch({ useMasterKey: true });
    +    const remainingRecovery = user.get('authData').mfa.recovery;
    +    expect(remainingRecovery).not.toContain(recoveryCode);
    +  });
    +});
    +
     describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field names in PostgreSQL adapter', () => {
       const headers = {
         'Content-Type': 'application/json',
    
  • src/Adapters/Storage/Mongo/MongoTransform.js+3 3 modified
    @@ -304,11 +304,11 @@ function transformQueryKeyValue(className, key, value, schema, count = false) {
           return { key: 'times_used', value: value };
         default: {
           // Other auth data
    -      const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/);
    +      const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)(\.(.+))?$/);
           if (authDataMatch && className === '_User') {
             const provider = authDataMatch[1];
    -        // Special-case auth data.
    -        return { key: `_auth_data_${provider}.id`, value };
    +        const subField = authDataMatch[3];
    +        return { key: `_auth_data_${provider}${subField ? `.${subField}` : ''}`, value };
           }
         }
       }
    
  • src/Adapters/Storage/Postgres/PostgresStorageAdapter.js+8 0 modified
    @@ -340,6 +340,14 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus
               patterns.push(`$${index}:raw = $${index + 1}::text`);
               values.push(name, fieldValue);
               index += 2;
    +        } else if (
    +          typeof fieldValue === 'object' &&
    +          !Object.keys(fieldValue).some(key => key.startsWith('$'))
    +        ) {
    +          name = transformDotFieldToComponents(fieldName).join('->');
    +          patterns.push(`($${index}:raw)::jsonb = $${index + 1}::jsonb`);
    +          values.push(name, JSON.stringify(fieldValue));
    +          index += 2;
             }
           }
         } else if (fieldValue === null || fieldValue === undefined) {
    
  • src/Routers/UsersRouter.js+29 6 modified
    @@ -286,12 +286,35 @@ export class UsersRouter extends ClassesRouter {
     
         // If we have some new validated authData update directly
         if (validatedAuthData && Object.keys(validatedAuthData).length) {
    -      await req.config.database.update(
    -        '_User',
    -        { objectId: user.objectId },
    -        { authData: validatedAuthData },
    -        {}
    -      );
    +      const query = { objectId: user.objectId };
    +      // Optimistic locking: include the original array fields in the WHERE clause
    +      // for providers whose data is being updated. This prevents concurrent requests
    +      // from both succeeding when consuming single-use tokens (e.g. MFA recovery codes).
    +      // Only array fields need locking — element removal is vulnerable to TOCTOU;
    +      // scalar fields are simply overwritten and don't have concurrency issues.
    +      if (user.authData) {
    +        for (const provider of Object.keys(validatedAuthData)) {
    +          const original = user.authData[provider];
    +          if (original && typeof original === 'object') {
    +            for (const [field, value] of Object.entries(original)) {
    +              if (
    +                Array.isArray(value) &&
    +                JSON.stringify(value) !== JSON.stringify(validatedAuthData[provider]?.[field])
    +              ) {
    +                query[`authData.${provider}.${field}`] = value;
    +              }
    +            }
    +          }
    +        }
    +      }
    +      try {
    +        await req.config.database.update('_User', query, { authData: validatedAuthData }, {});
    +      } catch (error) {
    +        if (error.code === Parse.Error.OBJECT_NOT_FOUND) {
    +          throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data');
    +        }
    +        throw error;
    +      }
         }
     
         const { sessionData, createSession } = RestWrite.createSession(req.config, {
    
fc3da35a81d5

fix: MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) (#10276)

4 files changed · +115 9
  • spec/vulnerabilities.spec.js+75 0 modified
    @@ -3964,3 +3964,78 @@ describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field n
         });
       });
     });
    +
    +describe('(GHSA-2299-ghjr-6vjp) MFA recovery code reuse via concurrent requests', () => {
    +  const mfaHeaders = {
    +    'X-Parse-Application-Id': 'test',
    +    'X-Parse-REST-API-Key': 'rest',
    +    'Content-Type': 'application/json',
    +  };
    +
    +  beforeEach(async () => {
    +    await reconfigureServer({
    +      auth: {
    +        mfa: {
    +          enabled: true,
    +          options: ['TOTP'],
    +          algorithm: 'SHA1',
    +          digits: 6,
    +          period: 30,
    +        },
    +      },
    +    });
    +  });
    +
    +  it('rejects concurrent logins using the same MFA recovery code', async () => {
    +    const OTPAuth = require('otpauth');
    +    const user = await Parse.User.signUp('mfauser', 'password123');
    +    const secret = new OTPAuth.Secret();
    +    const totp = new OTPAuth.TOTP({
    +      algorithm: 'SHA1',
    +      digits: 6,
    +      period: 30,
    +      secret,
    +    });
    +    const token = totp.generate();
    +    await user.save(
    +      { authData: { mfa: { secret: secret.base32, token } } },
    +      { sessionToken: user.getSessionToken() }
    +    );
    +
    +    // Get recovery codes from stored auth data
    +    await user.fetch({ useMasterKey: true });
    +    const recoveryCode = user.get('authData').mfa.recovery[0];
    +    expect(recoveryCode).toBeDefined();
    +
    +    // Send concurrent login requests with the same recovery code
    +    const loginWithRecovery = () =>
    +      request({
    +        method: 'POST',
    +        url: 'http://localhost:8378/1/login',
    +        headers: mfaHeaders,
    +        body: JSON.stringify({
    +          username: 'mfauser',
    +          password: 'password123',
    +          authData: {
    +            mfa: {
    +              token: recoveryCode,
    +            },
    +          },
    +        }),
    +      });
    +
    +    const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery()));
    +
    +    const succeeded = results.filter(r => r.status === 'fulfilled');
    +    const failed = results.filter(r => r.status === 'rejected');
    +
    +    // Exactly one request should succeed; all others should fail
    +    expect(succeeded.length).toBe(1);
    +    expect(failed.length).toBe(9);
    +
    +    // Verify the recovery code has been consumed
    +    await user.fetch({ useMasterKey: true });
    +    const remainingRecovery = user.get('authData').mfa.recovery;
    +    expect(remainingRecovery).not.toContain(recoveryCode);
    +  });
    +});
    
  • src/Adapters/Storage/Mongo/MongoTransform.js+3 3 modified
    @@ -304,11 +304,11 @@ function transformQueryKeyValue(className, key, value, schema, count = false) {
           return { key: 'times_used', value: value };
         default: {
           // Other auth data
    -      const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/);
    +      const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)(\.(.+))?$/);
           if (authDataMatch && className === '_User') {
             const provider = authDataMatch[1];
    -        // Special-case auth data.
    -        return { key: `_auth_data_${provider}.id`, value };
    +        const subField = authDataMatch[3];
    +        return { key: `_auth_data_${provider}${subField ? `.${subField}` : ''}`, value };
           }
         }
       }
    
  • src/Adapters/Storage/Postgres/PostgresStorageAdapter.js+8 0 modified
    @@ -339,6 +339,14 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus
               patterns.push(`$${index}:raw = $${index + 1}::text`);
               values.push(name, fieldValue);
               index += 2;
    +        } else if (
    +          typeof fieldValue === 'object' &&
    +          !Object.keys(fieldValue).some(key => key.startsWith('$'))
    +        ) {
    +          name = transformDotFieldToComponents(fieldName).join('->');
    +          patterns.push(`($${index}:raw)::jsonb = $${index + 1}::jsonb`);
    +          values.push(name, JSON.stringify(fieldValue));
    +          index += 2;
             }
           }
         } else if (fieldValue === null || fieldValue === undefined) {
    
  • src/Routers/UsersRouter.js+29 6 modified
    @@ -280,12 +280,35 @@ export class UsersRouter extends ClassesRouter {
     
         // If we have some new validated authData update directly
         if (validatedAuthData && Object.keys(validatedAuthData).length) {
    -      await req.config.database.update(
    -        '_User',
    -        { objectId: user.objectId },
    -        { authData: validatedAuthData },
    -        {}
    -      );
    +      const query = { objectId: user.objectId };
    +      // Optimistic locking: include the original array fields in the WHERE clause
    +      // for providers whose data is being updated. This prevents concurrent requests
    +      // from both succeeding when consuming single-use tokens (e.g. MFA recovery codes).
    +      // Only array fields need locking — element removal is vulnerable to TOCTOU;
    +      // scalar fields are simply overwritten and don't have concurrency issues.
    +      if (user.authData) {
    +        for (const provider of Object.keys(validatedAuthData)) {
    +          const original = user.authData[provider];
    +          if (original && typeof original === 'object') {
    +            for (const [field, value] of Object.entries(original)) {
    +              if (
    +                Array.isArray(value) &&
    +                JSON.stringify(value) !== JSON.stringify(validatedAuthData[provider]?.[field])
    +              ) {
    +                query[`authData.${provider}.${field}`] = value;
    +              }
    +            }
    +          }
    +        }
    +      }
    +      try {
    +        await req.config.database.update('_User', query, { authData: validatedAuthData }, {});
    +      } catch (error) {
    +        if (error.code === Parse.Error.OBJECT_NOT_FOUND) {
    +          throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data');
    +        }
    +        throw error;
    +      }
         }
     
         const { sessionData, createSession } = RestWrite.createSession(req.config, {
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.