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
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
challengeId | string (UUID) | ✓ | IsUUID() | Returned by POST /auth/send-otp |
code | string | ✓ | exactly 6 chars; regex ^\d{6}$ | Numeric OTP from SMS |
Response
200 OK — ApiResponseOf<VerifyOtpResponseDto>
{
"success": true,
"data": {
"success": true
}
}| Field | Type | Notes |
|---|---|---|
success | boolean | Always true on 200 |
accessToken | string | undefined | Reserved — currently always undefined. Will carry a JWT once SMS-OTP login bridge ships. |
expiresIn | number | undefined | Reserved — mirrors /auth/login.expiresIn semantics when present |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
401 | auth.otp.verify.invalid | Code mismatch — Challenge.attempts++ |
401 | auth.otp.verify.expired | Challenge.expiresAt is past |
401 | auth.otp.verify.attempts_exhausted | Challenge.attempts >= auth.otp_max_attempts |
401 | auth.otp.verify.already_used | Challenge.usedAt != null (single-use) |
429 | (throttle) | Rate limit exceeded (20 req/hour) |
Side effects
- Lookup
ChallengebychallengeId; verifyexpiresAt > now()ANDusedAt = nullANDattempts < auth.otp_max_attempts. - bcrypt-compare submitted
codeagainstChallenge.codeHash. - On success: set
Challenge.usedAt = now()(single-use lock). - On failure: increment
Challenge.attempts. If cap reached, all further attempts returnattempts_exhausted. - Audit log:
auth.otp.verify.successorauth.otp.verify.failure. - Calling flow consumes the verified Challenge to make its state change (e.g.,
verify-phone-fan→ setUser.phoneVerified = truein 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
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
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/auth/auth.controller.ts | 341–357 (verifyOtp) |
| DTO (request) | apps/api-core/src/modules/auth/dto/verify-otp.dto.ts | 4–14 (VerifyOtpDto) |
| DTO (response) | apps/api-core/src/modules/auth/dto/challenge.dto.ts | 60–80 (VerifyOtpResponseDto) |
| Service | apps/api-core/src/modules/auth/challenge.service.ts | verifyOtp() |
| Prisma model | packages/prisma/prisma/schema.prisma | Challenge.codeHash, Challenge.attempts, Challenge.usedAt |
Send Phone OTP
Initiate a 6-digit phone OTP. Creates a Challenge record and dispatches an SMS code via the active SMS provider (admin-managed). Returns challengeId for verify/resend.
Resend Phone OTP
Generate a fresh OTP code, reset attempts, dispatch a new SMS. Caps per challenge + cooldown between resends.