Skip to content

TOTP authentication

TOTP (Time-based One-Time Password, RFC 6238) is the six-digit codes from Google Authenticator, Authy, 1Password, Bitwarden, and similar apps. Every 30 seconds the code changes; both the user's app and the platform compute the same code from a shared secret and the current time.

TOTP is weaker than WebAuthn (the codes can be phished if the user is tricked into typing them on a malicious site), but it is dramatically better than no MFA, and it works on any device with an authenticator app — including users whose devices do not support WebAuthn.

const { startMfaEnrolment } = useIntelliAuth()
const enrolment = await startMfaEnrolment('totp')
// enrolment.kind === 'pending'
// enrolment.secret — the base32 shared secret
// enrolment.qr_code — a data URL for the QR
// enrolment.uri — the otpauth:// URI behind the QR

Your UI shows the QR code. The user scans it with their authenticator app — Google Authenticator, Authy, 1Password, the OS keychain, etc. The app stores the shared secret and starts generating codes.

To complete enrolment, the user types in the current code from their app:

const result = await enrolment.complete({ code: '123456' })
if (result.kind === 'success') {
// Enrolment is done. The user record now has a TOTP factor.
}

The platform verifies the code (allowing a one-step window of clock drift, ±30 seconds). On success, the secret is stored against the user; the QR / secret / pending state is discarded.

The QR encodes an otpauth:// URI. The IntelliAuth SDK returns both the data-URL QR (ready to render in an <img>) and the raw URI (so you can render the QR yourself if you want a specific size or styling).

Show the secret in text form beneath the QR. Some users cannot scan a QR — they need to type the secret into the app manually. Group it in chunks of 4 characters for readability:

JBSW Y3DP EHPK 3PXP

When MFA is required, the platform returns a flow_id and the available factors. If the user picks TOTP:

const { completeMfaStep } = useIntelliAuth()
const result = await completeMfaStep(flowId, 'totp', { code: '123456' })

The platform verifies and returns the session.

When the user enrolls TOTP, prompt them to also generate backup codes — a list of one-shot codes they can use if they lose access to their authenticator. The platform supports backup codes natively:

const { regenerateBackupCodes } = useIntelliAuth()
const codes = await regenerateBackupCodes()
// codes is an array of strings; show them once, never again

Show the codes once. The user copies them somewhere safe (password manager, printout). Treat them like an emergency credential. Each code is single-use; once consumed it's gone from the user's account.

The platform always returns the full fresh set when this is called — calling it again invalidates the previous set. Tell the user this is what will happen, or they'll be surprised when their saved-off codes stop working.

SymptomCauseFix
Code is correct but rejectedClock drift on the user's deviceMost apps let users sync time; suggest that
Code never worksWrong secret or wrong userRe-enrol; QR may have been mis-scanned
Code accepted but session failsDifferent problem; not TOTPCheck the audit log

The platform allows ±30 seconds of clock drift by default. Wider tolerance reduces security; tighter would frustrate users on devices with bad clocks.

The platform records the last successfully consumed code for each user. A code cannot be used twice — once accepted, it is burned. If the user tries to authenticate again in the same 30-second window, they wait for the next code.

TOTP and SMS solve the same UX problem (one-time code as a second factor), but TOTP is meaningfully stronger:

  • TOTP — the secret never leaves the user's device.
  • SMS — the code is sent over a network channel that has been compromised more than once (SIM-swap, SS7 attacks).

Prefer TOTP. SMS is a fallback for users whose devices cannot run an authenticator app, not a primary option.