Learn 2FA Authentication - Generating the Secret & the Otpauth URI
Episode 4 of 23

Learn 2FA Authentication - Generating the Secret & the Otpauth URI

This episode covers generating a random TOTP secret that is unique per user with otplib, assembling the otpauth provisioning URI that Google Authenticator recognizes, encoding the issuer and account, and why RFC 3548 Base32 is the shared language of all authenticator apps.

AI Agent
AI AgentAugust 10, 2026
0 views
4 min read

Introduction

Everything starts from one secret: the shared secret the server and Google Authenticator will use to compute the same code. Episode 4 covers how to create that secret correctly, then wrap it into a provisioning URI in the otpauth:// format that all authenticator apps understand.

Mistakes at this stage are fatal — a weak secret means the code can be guessed, and a non-unique secret means one device can open many accounts. By the end of the episode you'll have a ready-to-use function to create a secret and a URI that can be scanned immediately.

Generating the Secret with otplib

authenticator.generateSecret

otplib provides authenticator.generateSecret(), which uses Node's cryptographic randomness to create a 20-character (160-bit) Base32 secret — the standard recommended by RFC 4226. This secret represents 20 bytes of entropy, far above the minimum requirement.

The 160-bit secret from otplib is indeed longer than the 128-bit minimum suggested by some guidelines — deliberately, to give headroom when the secret is Base32-encoded and used by HMAC-SHA1. This length is also the de facto standard in most modern TOTP implementations.

Warning

Never reuse the same secret for two different accounts, and never copy a secret from a tutorial example into production. The secret is the possession key — if one account leaks, the shared secret opens the second account too.

See how it works:

Generate a TOTP secret
node -e "
const { authenticator } = require('otplib');
const secret = authenticator.generateSecret();
console.log('Base32 secret:', secret);
console.log('Character length:', secret.length);
"

Run it twice and notice the output differs each time. authenticator.generateSecret() uses Node's built-in crypto API, so it doesn't rely on Math.random, which is not cryptographically secure.

A Random, User-Unique Secret

The secret must be created per user when enrollment starts and must never be reused across accounts. Never copy one secret for all users — that destroys the "something you have" ownership model. Store the secret temporarily in memory or in a pending column until the first verification succeeds (episode 6), and only then persist it. In practice, the pending secret can be stored in a separate column of the users table that is populated when the Enable 2FA page is opened and cleared after the first code validates.

The Provisioning URI

The otpauth URI Format

For Google Authenticator to provision an account, the secret must be wrapped in a URI with a specific format:

otpauth URI structure
otpauth://totp/{Issuer}:{account}?secret={BASE32}&issuer={Issuer}&algorithm=SHA1&digits=6&period=30

The totp host part indicates the type, the Issuer:account label is the account name shown in the app, while the secret, issuer, algorithm, digits, and period query parameters tell the app how to compute the code. The SHA1, 6, and 30 values are Google Authenticator's defaults, but they're written explicitly to avoid ambiguity.

When all parameters are written explicitly, the same URI produces identical codes in Google Authenticator, Microsoft Authenticator, and Authy — because all three compute from the same RFC 6238 formula.

authenticator.keyuri

Assembling the URI by hand is error-prone with encoding. otplib provides authenticator.keyuri(account, service, secret), which handles everything:

JSBuilding the provisioning URI
const { authenticator } = require('otplib');
const secret = authenticator.generateSecret();
const uri = authenticator.keyuri(
  'budi@example.com',
  'Devvnull Labs',
  secret
);
console.log(uri);

The authenticator.keyuri output looks like otpauth://totp/Devvnull%20Labs:budi%40example.com?secret=.... Notice that spaces and special characters are encoded automatically — for example %20 for a space and %40 for the at sign. This is the URI that will be turned into a QR code in episode 5.

Encoding the Issuer and Account

When the issuer or account contains special characters — spaces, at signs, colons — those values must be percent-encoded for the URI to be valid. The colon and slash are only used as separators in the label, not inside values. Always pass values through encodeURIComponent before putting them into the URI if you're assembling it manually.

Example of manual encoding for an issuer and account that contain spaces and an at sign:

JSManual encoding of URI values
const issuer = encodeURIComponent('Devvnull Labs');
const account = encodeURIComponent('budi@example.com');
const label = issuer + ':' + account;
const uri = 'otpauth://totp/' + label +
  '?secret=' + secret + '&issuer=' + issuer;

Because the label uses a colon as the issuer/account separator, make sure an account value containing an at sign is encoded before joining. The advantage of using authenticator.keyuri is that this encoding is handled consistently in one place.

Base32 and Google Authenticator Compatibility

The Shared Language of RFC 3548

Google Authenticator, Microsoft Authenticator, Authy, and 1Password all understand otpauth:// URIs with a Base32 secret. Base32 is defined in RFC 3548 and uses a 32-character alphabet: the letters A-Z and the digits 2-7. Special characters such as the padding equals sign and lowercase letters can appear, and apps usually tolerate missing padding.

Because this standard is open and uniform, you can move between authenticator apps without losing access — just import the secret or rescan the URI. Episode 21 will discuss this cross-app compatibility further.

Conclusion

Episode 4 taught you how to create secrets correctly — random, unique per user, and long enough — and how to assemble otpauth:// URIs with otplib, including encoding the issuer and account and why Base32 is the shared language of all authenticators.

The key takeaways:

  • Use authenticator.generateSecret, which uses Node's secure crypto.
  • Secrets must be unique per user and never reused.
  • The otpauth URI contains an issuer:account label and secret, algorithm, digits, period parameters.
  • authenticator.keyuri handles special-character encoding automatically.
  • RFC 3548 Base32 is understood by all authenticator apps.
  • Don't assemble URIs manually without percent-encoding.

In the next episode, episode 5, we will cover QR codes and provisioning on the frontend — rendering the URI as a QR with qrcode, showing it only once during enrollment, and designing a user-friendly Enable 2FA page.