This episode tests 2FA thoroughly: validating TOTP against the official RFC 6238 test vectors with fake timers, testing clock skew and window tolerance, and E2E with a real Google Authenticator including the negative scenarios of wrong codes, expiry, and replay.

A 2FA feature orchestrates cryptography and time — two things that are hardest to test manually. Episode 16 covers a thorough testing strategy: unit tests with the official RFC 6238 test vectors, time testing with fake timers, and E2E with a real Google Authenticator.
Why can't this testing be postponed? Because TOTP bugs surface worst in production — codes sometimes accepted, sometimes not, locking users out and spiking support tickets. You'll build three layers of testing that catch these problems long before users feel them.
The three layers complement each other, not replace each other: unit tests prove the formula is correct, time tests prove the tolerance is reasonable, and E2E proves real users can get through the flow.
RFC 6238 contains a test vector table: pairs of time and code that are guaranteed correct for a given secret. These vectors are the benchmark — if the library produces the same codes, the implementation matches the standard. The secret used is the ASCII string "12345678901234567890" encoded in Base32.
Test with fake timers and the 8-digit SHA1 vector:
const { authenticator } = require('otplib');
const SECRET = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
jest.useFakeTimers();
jest.setSystemTime(new Date('1970-01-01T00:00:59.000Z'));
test('TOTP matches the RFC 6238 vector', () => {
authenticator.options = { algorithm: 'SHA1', digits: 8, step: 30 };
expect(authenticator.generate(SECRET)).toBe('94287082');
});The time 1970-01-01 00:00:59 yields counter 1, and the RFC 6238 vector for counter 1 with SHA1 and 8 digits is 94287082. jest.setSystemTime replaces the real clock so the test doesn't depend on the currently running time.
For convenience, set this secret as a constant in a test helper, so all test files use the same source. If the secret changes in one place without changing the others, a test failure immediately reveals the inconsistency.
A single vector confirms the basics, not the whole. Test several points: counter 0, a large counter value, digits 6 and 8, plus the SHA256 and SHA512 algorithms that also have vectors in the RFC. Each combination covers a different code branch inside the library and ensures the configuration options translate correctly.
Also use a simple round-trip test: generate with otplib then verify with check at the same time, to ensure the generate/verify pair behaves consistently beyond the static vectors.
TOTP lives and dies by clock synchronization. Test the tolerance by advancing time past the step boundary:
authenticator.options = { step: 30, window: 1 };
jest.setSystemTime(new Date('1970-01-01T00:00:00.000Z'));
const kodeAwal = authenticator.generate(SECRET);
jest.setSystemTime(new Date('1970-01-01T00:00:45.000Z'));
const kodeBerikutnya = authenticator.generate(SECRET);
expect(authenticator.check(kodeAwal, SECRET)).toBe(true);
expect(authenticator.check(kodeBerikutnya, SECRET)).toBe(true);At second 45, the current step is 1 while kodeAwal comes from step 0. With window 1, the old code is still accepted — exactly the behavior that keeps users with a slightly slow clock from being locked out.
Also test the failing side: a code from a step far outside the window must be rejected, and the same code must not be accepted twice (replay). These two scenarios are the ones most often missed in manual testing and the most dangerous in production — episode 10 built the lastUsedStep mechanism that must be tested here.
For simulating a deviating clock, some frameworks provide helpers to advance the system time in the test process — use them carefully and restore the real clock afterwards so other tests aren't disturbed.
Unit tests prove the math; E2E proves compatibility. Run the app on local HTTPS (episode 13), open the Enable 2FA page, and scan the QR with a real Google Authenticator:
1. Scan the QR with Google Authenticator
2. Verify the first code in the confirmation form
3. Logout then log in again
4. Enter the TOTP code from the app
5. Test a recovery code after removing the account from the appTesting with a real device catches things invisible to unit tests: scan compatibility, the format of the displayed secret, and the authenticator app's behavior on a specific version.
Do this test on several devices: Android and iOS use different Google Authenticator versions, and import/export behavior can differ between platforms.
Complete the set with negative scenarios: a mistyped code is rejected, an expired code is rejected, the same code used twice is rejected, an already-used recovery code is rejected, and rate limiting blocks after 5 attempts. Record the results in a checklist that can be repeated at every release — this protects against regressions silently sneaking in with other changes.
Automate these scenarios via integration tests where possible, and keep the manual output as a release attachment — test evidence is often requested in audits.
Episode 16 completed testing with three layers: unit tests against the RFC 6238 vectors with fake timers, clock skew and window tolerance tests, and E2E with a real Google Authenticator including all the negative scenarios.
The key takeaways:
In the next episode, episode 17, we will cover beyond TOTP: WebAuthn and passkeys — the phishing-resistant FIDO2 principles without a shared secret, a comparison of TOTP versus WebAuthn versus SMS, and a strategy for offering both at once.