TOTP (Authenticator App)
Set up TOTP-based MFA in HelloJohn — enrollment flow, QR code generation, verification, and SDK integration for Google Authenticator, Authy, and 1Password.
TOTP (Time-based One-Time Password, RFC 6238) generates a 6-digit code that changes every 30 seconds. Users install any standard authenticator app — Google Authenticator, Authy, 1Password, Bitwarden — and scan a QR code to enroll.
Enrollment flow
Initiate enrollment
POST /v2/auth/mfa/totp/enroll
Authorization: Bearer {access_token}Response:
{
"enrollment_id": "enroll_01HX...",
"totp_uri": "otpauth://totp/HelloJohn:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=HelloJohn&algorithm=SHA1&digits=6&period=30",
"secret": "JBSWY3DPEHPK3PXP",
"qr_code_svg": "<svg>...</svg>"
}Display the QR code (qr_code_svg) or the raw totp_uri for the user to scan.
User scans QR code
The user opens their authenticator app and scans the QR code. The app displays a 6-digit code.
Verify and complete enrollment
The user enters the code to confirm they scanned it correctly:
POST /v2/auth/mfa/totp/enroll/verify
Authorization: Bearer {access_token}
Content-Type: application/json
{
"enrollment_id": "enroll_01HX...",
"code": "123456"
}Response 200 OK:
{
"method_id": "mfa_01HX...",
"backup_codes": ["XXXX-XXXX", "YYYY-YYYY", ...]
}Save the backup codes — they're only shown once. See Backup Codes →.
Verification (login challenge)
When the user logs in and TOTP is their enrolled MFA method:
POST /v2/auth/mfa/totp/verify
Content-Type: application/json
{
"challenge_id": "challenge_01HX...",
"code": "123456"
}Response 200 OK — full session tokens issued:
{
"access_token": "eyJ...",
"refresh_token": "rt_...",
"expires_in": 900
}SDK integration
import { useMFA, useTOTPEnrollment } from '@hellojohn/react'
// Enrollment component
function TOTPEnrollment() {
const { startTOTPEnrollment, verifyTOTPEnrollment } = useTOTPEnrollment()
const [qrCode, setQrCode] = useState<string | null>(null)
const [enrollmentId, setEnrollmentId] = useState<string | null>(null)
async function start() {
const { enrollment_id, qr_code_svg } = await startTOTPEnrollment()
setEnrollmentId(enrollment_id)
setQrCode(qr_code_svg)
}
async function verify(code: string) {
await verifyTOTPEnrollment(enrollmentId!, code)
// Enrollment complete
}
if (!qrCode) {
return <button onClick={start}>Set up authenticator app</button>
}
return (
<div>
<div dangerouslySetInnerHTML={{ __html: qrCode }} />
<p>Scan this with your authenticator app, then enter the code:</p>
<TOTPCodeInput onSubmit={verify} />
</div>
)
}
// Challenge component (at login)
function TOTPChallenge({ challengeId }: { challengeId: string }) {
const { submitTOTP } = useMFA()
return (
<form onSubmit={e => {
e.preventDefault()
submitTOTP(challengeId, (e.target as any).code.value)
}}>
<input
name="code"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
autoComplete="one-time-code"
placeholder="000000"
required
/>
<button type="submit">Verify</button>
</form>
)
}'use client'
import { useHelloJohnMFA } from '@hellojohn/nextjs'
export function TOTPChallenge({ challengeId }: { challengeId: string }) {
const { verifyTOTP } = useHelloJohnMFA()
async function action(formData: FormData) {
const code = formData.get('code') as string
await verifyTOTP(challengeId, code)
// Automatically redirects to original destination
}
return (
<form action={action}>
<input
name="code"
type="text"
inputMode="numeric"
maxLength={6}
autoComplete="one-time-code"
required
/>
<button type="submit">Verify</button>
</form>
)
}Clock drift tolerance
TOTP codes are time-based. HelloJohn accepts codes from the current window ± 1 window (±30 seconds) to account for clock drift between the server and the user's device.
If a user's codes are consistently rejected, check:
- The server's system clock is synced (NTP)
- The user's device clock is accurate
Removing TOTP
Users can remove TOTP from their account:
DELETE /v2/auth/mfa/{methodId}
Authorization: Bearer {access_token}If TOTP is the user's only MFA method and MFA is required for their tenant, this request will fail. They must enroll a different method first.
Admin override:
DELETE /v2/admin/users/{userId}/mfa/{methodId}
Authorization: Bearer $ADMIN_TOKEN