This episode turns the provisioning URI into a QR code that Google Authenticator can scan, renders it on the server with qrcode and shows it only once during enrollment, plus designs the Enable 2FA page with a manual secret option for users without a camera.

The otpauth:// URI from episode 4 is still a long text that's awkward to type. Episode 5 bridges that URI to the user's smartphone via a QR code — a visual format Google Authenticator can scan in one second.
You'll learn to render the QR server-side with the qrcode package, show it only once during enrollment, and design a complete Enable 2FA page: the main QR, a manual secret option, and installation instructions for the authenticator app. By the end of the episode, users can scan and their account appears in Google Authenticator.
The qrcode package can render a QR in various forms, including a PNG data URL that's inserted straight into an image tag. Encode the provisioning URI as a data URL in the enrollment route:
const qrcode = require('qrcode');
app.get('/account/enable-2fa', async (req, res) => {
const secret = authenticator.generateSecret();
const uri = authenticator.keyuri(req.user.email, 'Devvnull Labs', secret);
const qr = await qrcode.toDataURL(uri, { width: 256, margin: 2 });
res.json({ qr, secret, uri });
});The qr data URL is a data:image/png;base64,... string that can be rendered directly on the page. The qrcode.toDataURL method uses the best Reed-Solomon error correction by default, so the QR stays readable even if part of the image is covered.
A data URL carries the entire PNG image, so deliberately don't put it in logs or store it permanently — use it only as a one-time response to the enrollment page.
The provisioning URI contains a secret — anyone who holds it can provision the user's account. So the QR and the manual secret must only appear once, right at enrollment, and must never be served again after 2FA is active. Also restrict access to this route to users whose totp_enabled is still false.
The qrcode package offers error correction levels L, M, Q, and H — the higher the level, the more patterns can be recovered if the image is covered or damaged, but the denser the modules produced. For a QR scanned from a smartphone screen, level M is sufficient, while level H is useful for QRs printed on physical media.
Set the image width between 256 and 320 pixels. Too small makes scanning hard from close range, too large adds no accuracy. Example rendering with an explicit error correction level:
const qr = await qrcode.toDataURL(uri, {
errorCorrectionLevel: 'H',
width: 320,
margin: 4,
color: {
dark: '#000000',
light: '#ffffff'
}
});Black-on-white contrast is the easiest combination to scan. Avoid QRs with light-on-light colors or busy background patterns, and make sure there's a blank quiet zone around the QR so the camera can recognize its edges.
If the QR will be printed, increase the margin and use level H; if it's only shown on screen, level M is enough to balance density and readability.
Consistent sizing also helps: keep 256 pixels for the on-screen version and 512 pixels for the download or print version, so the QR's visual identity stays stable everywhere it appears.
The enrollment page layout shows the QR in the center, with the Base32 secret below it as a manual option. Users with a broken camera or who refuse to scan can type the secret straight into Google Authenticator via the "Enter a setup key" button. Give a copy-secret button and a warning that this secret is only displayed once.
Make sure the copy button never copies the full URI to the clipboard — only the secret — so the URI carrying the issuer and parameters doesn't spread unintentionally.
The page should also guide users who don't have the app yet: links to Google Play and the App Store, the steps to scan the QR, and what they'll see after success — a new account showing 6 digits that tick every 30 seconds. Write short, concrete steps so enrollment completes without manual support.
Effective step-by-step instructions look like a short list: open the app, press the add button, choose "Scan a QR code", point the camera at the QR, then wait for the account to appear. Once it appears, don't close the page right away — the first verification step in episode 6 must be done while the code is still visible in the app.
An enrollment page is only valuable if the flow around it is correct: the secret doesn't leak before scanning, and the account isn't activated before verification. The following two subsections set the ground rules.
One golden rule: don't enable 2FA before the first code is verified. A user could scan the wrong QR, type a truncated secret, or their app might not have finished syncing. Episode 6 handles this first verification, but the design decision — a QR without automatic activation — is settled from the moment this page is designed.
The secret shown on the Enable 2FA page should expire — for example after 10 minutes — to prevent a QR left open on screen from being scanned by someone else. After expiry, the user restarts enrollment with a new secret, and the old secret is considered invalid. Record the secret creation time inside the server's enrollment payload.
Besides the QR, show the Base32 secret below the QR with a copy button. Some users prefer typing it manually into an authenticator app without a camera. Keep this small decision in one component so the behavior stays consistent: once rendered, the page must not refresh and show a new secret without user interaction.
The Enable 2FA page holds sensitive information. Make sure the route requires a valid session, set headers like Content-Security-Policy that forbid script payloads from foreign domains, and show a visual warning if the page isn't served over HTTPS. This header hardening is covered in full in episode 13.
Episode 5 turned the provisioning URI into a scan-friendly QR code: rendered server-side with qrcode, displayed once, and wrapped in an Enable 2FA page that guides users from installing the app to the first account appearing.
The key takeaways:
In the next episode, episode 6, we will cover first-code verification (enrollment confirmation) — validating the 6 digits the user typed against the secret, applying a time window tolerance, then enabling totpEnabled, and finally showing the recovery codes.