BIO.RE
Authentication

Verify Phone OTP

Verify the 6-digit OTP code against the Challenge. On success, the Challenge is marked used and the calling flow can advance.

POST /api/v1/auth/verify-otp — 🌐 Public · Rate limit: 20 req / hour

Verifies the 6-digit code against Challenge.codeHash (bcrypt). On success, the Challenge is marked as used (Challenge.usedAt = now()). The calling flow consumes the verified Challenge to gate its own state change (e.g., flip User.phoneVerified = true).

The response shape includes optional accessToken + expiresIn fields reserved for a future SMS-OTP login bridge. They are currently always absent — for the login 2FA path, continue using POST /auth/login/2fa with TOTP / backup codes.

Request

Body — VerifyOtpDto

FieldTypeRequiredValidationNotes
challengeIdstring (UUID)IsUUID()Returned by POST /auth/send-otp
codestringexactly 6 chars; regex ^\d{6}$Numeric OTP from SMS

Response

200 OKApiResponseOf<VerifyOtpResponseDto>

{
  "success": true,
  "data": {
    "success": true
  }
}
FieldTypeNotes
successbooleanAlways true on 200
accessTokenstring | undefinedReserved — currently always undefined. Will carry a JWT once SMS-OTP login bridge ships.
expiresInnumber | undefinedReserved — mirrors /auth/login.expiresIn semantics when present

Errors

HTTPcode / i18nKeyReason
401auth.otp.verify.invalidCode mismatch — Challenge.attempts++
401auth.otp.verify.expiredChallenge.expiresAt is past
401auth.otp.verify.attempts_exhaustedChallenge.attempts >= auth.otp_max_attempts
401auth.otp.verify.already_usedChallenge.usedAt != null (single-use)
429(throttle)Rate limit exceeded (20 req/hour)

Side effects

  1. Lookup Challenge by challengeId; verify expiresAt > now() AND usedAt = null AND attempts < auth.otp_max_attempts.
  2. bcrypt-compare submitted code against Challenge.codeHash.
  3. On success: set Challenge.usedAt = now() (single-use lock).
  4. On failure: increment Challenge.attempts. If cap reached, all further attempts return attempts_exhausted.
  5. Audit log: auth.otp.verify.success or auth.otp.verify.failure.
  6. Calling flow consumes the verified Challenge to make its state change (e.g., verify-phone-fan → set User.phoneVerified = true in the next call). This endpoint does NOT make user-facing mutations beyond the Challenge row.

Code samples

curl -X POST https://api.bio.re/api/v1/auth/verify-otp \
  -H 'Content-Type: application/json' \
  -d '{
    "challengeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "code": "123456"
  }'
type VerifyOtpResponse = {
  success: boolean;
  accessToken?: string;
  expiresIn?: number;
};

async function verifyOtp(challengeId: string, code: string): Promise<VerifyOtpResponse> {
  const res = await fetch('https://api.bio.re/api/v1/auth/verify-otp', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ challengeId, code }),
  });
  const json = await res.json();
  if (!res.ok || !json.success) {
    throw Object.assign(new Error(json?.error?.message ?? 'Verify OTP failed'), {
      code: json?.error?.code,
    });
  }
  return json.data;
}
import { useMutation } from '@tanstack/react-query';

export function useVerifyOtp() {
  return useMutation({
    mutationFn: async (input: { challengeId: string; code: string }) => {
      const res = await fetch('/api/v1/auth/verify-otp', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      });
      const json = await res.json();
      if (!res.ok || !json.success) {
        throw Object.assign(new Error(json?.error?.message ?? 'Verify OTP failed'), {
          code: json?.error?.code,
          i18nKey: json?.error?.i18nKey,
        });
      }
      return json.data as VerifyOtpResponse;
    },
  });
}

Try it

POST
/api/v1/auth/verify-otp

Request Body

application/json

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

curl -X POST "https://loading/api/v1/auth/verify-otp" \  -H "Content-Type: application/json" \  -d '{    "challengeId": "007cfdcc-a46d-4340-a4c6-216ec2e4009c",    "code": "123456"  }'
{
  "success": true,
  "data": {
    "success": true,
    "accessToken": "eyJhbGciOiJIUzI1NiIs...",
    "expiresIn": 900
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}

Source

SourcePathLines
Controllerapps/api-core/src/modules/auth/auth.controller.ts341–357 (verifyOtp)
DTO (request)apps/api-core/src/modules/auth/dto/verify-otp.dto.ts4–14 (VerifyOtpDto)
DTO (response)apps/api-core/src/modules/auth/dto/challenge.dto.ts60–80 (VerifyOtpResponseDto)
Serviceapps/api-core/src/modules/auth/challenge.service.tsverifyOtp()
Prisma modelpackages/prisma/prisma/schema.prismaChallenge.codeHash, Challenge.attempts, Challenge.usedAt

On this page