BIO.RE
Authentication

Init 2FA Setup (Stable Shape)

Alias of POST /auth/2fa/setup with an explicit recoveryCodes:null contract — kept thin so the frontend type stays stable across setup → verify.

POST /api/v1/auth/2fa/setup-init — 🔑 Bearer · Rate limit: 10 req / hour

Alias of POST /auth/2fa/setup with one cosmetic difference: the response shape includes recoveryCodes: null so the frontend can use a single response type across setup → verify without conditional fields.

Behavior is identical to POST /auth/2fa/setup — same secret generation, same encrypted persistence, same already_enabled guard. The only difference is the explicit recoveryCodes: null field. Recovery codes are still issued by POST /auth/2fa/verify, never here.

Why two endpoints

The original /setup returns { secret, qrCodeDataUrl, otpauthUrl }. After /verify succeeds, the response is { backupCodes: [...] }. The shapes don't overlap. /setup-init returns { secret, qrCodeUrl, otpauthUrl, recoveryCodes: null } so a single TS type covers both stages — recoveryCodes flips from null (setup-init) to string[] (verify), nothing else changes.

Use /setup if you want the canonical endpoint. Use /setup-init if you need the stable response shape across the two-step flow.

Request

No body. The current user is resolved from the bearer token.

HeaderRequiredNotes
Authorization: Bearer <accessToken>JWT from POST /auth/login

Response

200 OKApiResponseOf<TwoFactorSetupInitResponseDto>

{
  "success": true,
  "data": {
    "secret": "JBSWY3DPEHPK3PXP",
    "qrCodeUrl": "data:image/png;base64,iVBORw0KGgo...",
    "otpauthUrl": "otpauth://totp/BIO.RE:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=BIO.RE",
    "recoveryCodes": null
  }
}
FieldTypeNotes
secretstring (base32)TOTP secret for manual entry
qrCodeUrlstringdata:image/png;base64,… — note the field name is qrCodeUrl, not qrCodeDataUrl (the only naming drift from /setup)
otpauthUrlstringotpauth://totp/... deep-link
recoveryCodesnullAlways null here. Recovery codes are issued by POST /auth/2fa/verify after the user proves TOTP works. Forward-compat field so the type stays stable.

Errors

HTTPcode / i18nKeyReason
400auth.2fa.already_enabledUser.twoFactorEnabled is already true
401(guard)Missing / invalid bearer token
404auth.2fa.user_not_foundToken decoded but user row missing
429(throttle)Rate limit exceeded (10 req/hour)

Side effects

Identical to POST /auth/2fa/setup:

  1. Lookup User; assert twoFactorEnabled = false.
  2. Generate fresh TOTP secret (otplib.generateSecret()).
  3. Build otpauth:// URL + render QR PNG data URL.
  4. AES-256-GCM-encrypt the secret and persist to User.twoFactorSecret.
  5. No backup code generation — that's deferred to /verify.

Code samples

curl -X POST https://api.bio.re/api/v1/auth/2fa/setup-init \
  -H "Authorization: Bearer $ACCESS_TOKEN"
type TwoFactorSetupInit = {
  secret: string;
  qrCodeUrl: string;
  otpauthUrl: string;
  recoveryCodes: string[] | null; // null on setup-init, string[] after verify
};

async function initTwoFactorSetup(accessToken: string): Promise<TwoFactorSetupInit> {
  const res = await fetch('https://api.bio.re/api/v1/auth/2fa/setup-init', {
    method: 'POST',
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  const json = await res.json();
  if (!res.ok || !json.success) {
    throw Object.assign(new Error(json?.error?.message ?? '2FA setup-init failed'), {
      code: json?.error?.code,
    });
  }
  return json.data;
}
import { useMutation } from '@tanstack/react-query';

export function useInitTwoFactorSetup() {
  return useMutation({
    mutationFn: async () => {
      const res = await fetch('/api/v1/auth/2fa/setup-init', { method: 'POST' });
      const json = await res.json();
      if (!res.ok || !json.success) {
        throw Object.assign(new Error(json?.error?.message ?? '2FA setup-init failed'), {
          code: json?.error?.code,
          i18nKey: json?.error?.i18nKey,
        });
      }
      return json.data as TwoFactorSetupInit;
    },
  });
}

Try it

POST
/api/v1/auth/2fa/setup-init
AuthorizationBearer <token>

In: header

Response Body

application/json

application/json

application/json

curl -X POST "https://loading/api/v1/auth/2fa/setup-init"
{
  "success": true,
  "data": {
    "secret": "JBSWY3DPEHPK3PXP",
    "qrCodeUrl": "string",
    "otpauthUrl": "otpauth://totp/BIO.RE:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=BIO.RE",
    "recoveryCodes": [
      "string"
    ]
  }
}
{
  "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/two-factor.controller.ts30–50 (setupInit)
DTO (response)apps/api-core/src/modules/auth/dto/two-factor-setup.dto.ts15–34 (TwoFactorSetupInitResponseDto)
Serviceapps/api-core/src/modules/auth/two-factor.service.ts31–50 (generateSetup, shared)
Prisma modelpackages/prisma/prisma/schema.prismaUser.twoFactorSecret, User.twoFactorEnabled

On this page