Skip to main content
POST
/
auth
/
2fa
Enable 2FA
curl --request POST \
  --url https://auth.nullpass.xyz/api/auth/2fa \
  --header 'Authorization: Bearer <token>'
{
  "secret": "<string>",
  "qrCode": "<string>",
  "backupCodes": [
    "<string>"
  ]
}

Endpoint

POST /api/auth/2fa

Overview

Manages two-factor authentication (2FA) using TOTP (Time-based One-Time Password). The process involves two steps for enabling: generating a QR code, then confirming with a verification code.

Enable 2FA (Step 1: Generate QR Code)

Request

enable
boolean
required
Must be true

Response

qrCode
string
Base64-encoded PNG QR code image (data URI format)
secret
string
Base32-encoded secret key for manual entry
manualEntryKey
string
Same as secret (for compatibility)
message
string
“Scan the QR code with your authenticator app”

Enable 2FA (Step 2: Confirm)

Request

enable
boolean
required
Must be true
secret
string
required
Secret from Step 1 response
verificationCode
string
required
TOTP code from authenticator app (6 digits)

Response

message
string
“2FA enabled successfully”
twoFactorEnabled
boolean
Always true

Disable 2FA

Request

enable
boolean
required
Must be false
verificationCode
string
required
Current TOTP code from authenticator app

Response

message
string
“2FA disabled successfully”
twoFactorEnabled
boolean
Always false

Implementation Details

Process Flow

Enable (Step 1):
  1. Generates secret using speakeasy (32 characters, base32)
  2. Creates OTPAuth URL with issuer and label
  3. Generates QR code as base64 data URI
  4. Returns QR code and secret (not saved yet)
Enable (Step 2):
  1. Verifies TOTP code with provided secret (window: 2)
  2. Saves secret to user record
  3. Sets twoFactorEnabled to true
  4. Logs audit event
Disable:
  1. Verifies 2FA is enabled
  2. Verifies TOTP code with stored secret
  3. Clears secret and disables 2FA
  4. Logs audit event

Code Reference

export async function POST(request: NextRequest) {
  const corsResponse = handleCors(request)
  if (corsResponse) return corsResponse

  const blocked = await protectRoute(request)
  if (blocked) return blocked

  const auth = await requireAuth(request)
  if ('error' in auth) return auth.error

  try {
    const body = await request.json()
    const validated = toggle2FASchema.parse(body)

    const user = await prisma.user.findUnique({
      where: { id: auth.userId },
      select: {
        id: true,
        email: true,
        displayName: true,
        twoFactorEnabled: true,
        twoFactorSecret: true,
      },
    })

    if (!user) {
      return errorResponse('User not found', 404, request.headers.get('origin'))
    }

    if (validated.enable) {
      if (validated.verificationCode && validated.secret) {
        const verified = speakeasy.totp.verify({
          secret: validated.secret,
          encoding: 'base32',
          token: validated.verificationCode,
          window: 2,
        })

        if (!verified) {
          return errorResponse('Invalid verification code', 400, request.headers.get('origin'))
        }

        await prisma.user.update({
          where: { id: auth.userId },
          data: {
            twoFactorEnabled: true,
            twoFactorSecret: validated.secret,
          },
        })

        await createAuditLog(auth.userId, 'TWO_FACTOR_ENABLE', {})

        return jsonResponse(
          {
            message: '2FA enabled successfully',
            twoFactorEnabled: true,
          },
          200,
          request.headers.get('origin')
        )
      } else {
        const generatedSecret = speakeasy.generateSecret({
          name: `Nullpass (${user.email})`,
          issuer: 'Nullpass',
          length: 32,
        })
        const secret = generatedSecret.base32

        const otpauthUrl = speakeasy.otpauthURL({
          secret: secret,
          label: user.email,
          issuer: 'Nullpass',
          encoding: 'base32',
        })

        const qrCodeUrl = await QRCode.toDataURL(otpauthUrl)

        return jsonResponse(
          {
            qrCode: qrCodeUrl,
            secret: secret,
            manualEntryKey: secret,
            message: 'Scan the QR code with your authenticator app',
          },
          200,
          request.headers.get('origin')
        )
      }
    } else {
      if (!validated.verificationCode) {
        return errorResponse('2FA verification code is required to disable 2FA', 400, request.headers.get('origin'))
      }

      if (!user.twoFactorEnabled || !user.twoFactorSecret) {
        return errorResponse('2FA is not enabled', 400, request.headers.get('origin'))
      }

      const verified = speakeasy.totp.verify({
        secret: user.twoFactorSecret,
        encoding: 'base32',
        token: validated.verificationCode,
        window: 2,
      })

      if (!verified) {
        return errorResponse('Invalid verification code', 400, request.headers.get('origin'))
      }

      await prisma.user.update({
        where: { id: auth.userId },
        data: {
          twoFactorEnabled: false,
          twoFactorSecret: null,
        },
      })

      await createAuditLog(auth.userId, 'TWO_FACTOR_DISABLE', {})

      return jsonResponse(
        {
          message: '2FA disabled successfully',
          twoFactorEnabled: false,
        },
        200,
        request.headers.get('origin')
      )
    }
  } catch (error: any) {
    if (error.name === 'ZodError') {
      logger.warn('2FA toggle validation error:', error.errors)
      return errorResponse(error.errors[0].message, 400, request.headers.get('origin'))
    }
    logger.error('2FA toggle error:', error)
    return errorResponse('Internal server error', 500, request.headers.get('origin'))
  }
}

Status Codes

200
OK
Success
400
Bad Request
Validation error, invalid verification code, or 2FA not enabled when disabling
401
Unauthorized
Missing or invalid authentication token
404
Not Found
User not found

Example Requests

Generate QR Code

curl -X POST https://auth.nullpass.xyz/api/auth/2fa \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "enable": true
  }'

Confirm Enable

curl -X POST https://auth.nullpass.xyz/api/auth/2fa \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "enable": true,
    "secret": "JBSWY3DPEHPK3PXP",
    "verificationCode": "123456"
  }'

Disable 2FA

curl -X POST https://auth.nullpass.xyz/api/auth/2fa \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "enable": false,
    "verificationCode": "123456"
  }'

TOTP Configuration

  • Algorithm: SHA1
  • Digits: 6
  • Period: 30 seconds
  • Window: 2 (allows codes from ±1 time step)

Supported Authenticator Apps

  • Google Authenticator
  • Authy
  • 1Password
  • Microsoft Authenticator
  • Any TOTP-compatible app

Security Notes

  • Secret is stored in database (encrypted at rest if database encryption is enabled)
  • QR code contains OTPAuth URL with issuer and email label
  • Verification window of 2 allows for slight clock drift
  • Secret is cleared when 2FA is disabled
  • All 2FA operations are logged in audit trail

Audit Events

  • TWO_FACTOR_ENABLE: 2FA enabled
  • TWO_FACTOR_DISABLE: 2FA disabled

Authorizations

Authorization
string
header
required

Bearer authentication header of the form Bearer <token>, where <token> is your auth token.

Response

200 - application/json

2FA enabled

secret
string
qrCode
string
backupCodes
string[]