How to test totp
TOTP is HMAC-SHA1 over the current 30 second time step, so you can compute the code yourself instead of reading a phone. Save the generator below and run it against the account's base32 secret. It prints the six digits the authenticator app is showing, and the counter the server compares them against.
Why check this
Run this before any test of a login that asks for a code, and again after the auth host moves or loses its NTP source. Without a generator, a rejected code has three possible owners: the user, the app and the server clock. With one, you separate them in a single command.
The failure it prevents is specific. An auth server whose clock drifts past one time step refuses every code from every correctly enrolled user, and the login page reports "invalid code", which reads to support like a password problem. Step 6 reproduces that drift and shows the exact offset where acceptance stops.
Prerequisites
- Node 22 or later. Save the file below as
totp.js. No dependencies. - The base32 secret for a test account, copied from the enrolment screen. RFC 6238 defines the algorithm, and RFC 4226 defines the truncation it reuses.
- An idea of the server's window setting, which the server team can tell you faster than a test can.
// totp.js - RFC 6238 TOTP in Node 22, no dependencies.
const crypto = require('node:crypto');
// RFC 4648 base32, the alphabet an authenticator app shows as the shared secret.
function base32(s) {
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
for (const c of s.replace(/=+$/, '').toUpperCase()) bits += A.indexOf(c).toString(2).padStart(5, '0');
const out = [];
for (let i = 0; i + 8 <= bits.length; i += 8) out.push(parseInt(bits.slice(i, i + 8), 2));
return Buffer.from(out);
}
function totp(secret, { t = Date.now() / 1000, step = 30, digits = 6, alg = 'sha1' } = {}) {
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(Math.floor(t / step))); // RFC 6238: T = floor(unixtime / step)
const mac = crypto.createHmac(alg, base32(secret)).update(counter).digest();
const off = mac[mac.length - 1] & 0x0f; // RFC 4226 dynamic truncation
const bin = mac.readUInt32BE(off) & 0x7fffffff;
return String(bin % 10 ** digits).padStart(digits, '0');
}
function verify(secret, code, { window = 0, t = Date.now() / 1000, step = 30 } = {}) {
for (let w = -window; w <= window; w += 1)
if (totp(secret, { t: t + w * step, step }) === code) return w; // the step offset that matched
return null;
}
module.exports = { totp, verify, base32 };
Steps
- Step 1.
Check the generator against the RFC 6238 Appendix B vectors before trusting anything it says. The seed there is the ASCII string
12345678901234567890, which isGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQin base32.node -e "const {totp}=require('./totp.js'); const S='GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'; for (const t of [59,1111111109,1111111111,1234567890,2000000000,20000000000]) console.log(String(t).padStart(11), totp(S,{t,digits:8}));"59 94287082 1111111109 07081804 1111111111 14050471 1234567890 89005924 2000000000 69279037 20000000000 65353130All six match the SHA-1 column of the Appendix B table. A generator that fails this is testing itself, not the server.
- Step 2.
Build the enrolment URI for the test account, so the same secret sits in the generator and in a real authenticator app.
node -e "const S='JBSWY3DPEHPK3PXP'; console.log('otpauth://totp/Demo:alice@example.com?secret='+S+'&issuer=Demo&algorithm=SHA1&digits=6&period=30');"otpauth://totp/Demo:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Demo&algorithm=SHA1&digits=6&period=30periodanddigitsare the two parameters a server most often disagrees with the app about. Render the URI as a QR code only when you need the app in the loop. - Step 3.
Print the current code with the counter it came from and the seconds left in the step.
node -e "const {totp}=require('./totp.js'); const S='JBSWY3DPEHPK3PXP'; const t=Math.floor(Date.now()/1000); console.log('unix time ', t); console.log('counter T ', Math.floor(t/30)); console.log('code ', totp(S,{t})); console.log('seconds left ', 30-(t%30));"unix time 1789160468 counter T 59638682 code 253896 seconds left 22The counter is the number the server hashes. Quote it in the bug report.
- Step 4.
Generate the code at several offsets inside one step and across the boundary.
node -e "const {totp}=require('./totp.js'); const S='JBSWY3DPEHPK3PXP'; const b=Math.floor(Date.now()/1000/30)*30; for (const o of [0,1,15,29,30,31]) console.log('t+'+String(o).padStart(2), ' counter', Math.floor((b+o)/30), ' code', totp(S,{t:b+o}));"t+ 0 counter 59638682 code 253896 t+ 1 counter 59638682 code 253896 t+15 counter 59638682 code 253896 t+29 counter 59638682 code 253896 t+30 counter 59638683 code 171016 t+31 counter 59638683 code 171016One code covers a whole step, not an instant. The code at
t+30starts with a zero, which is why codes are compared as strings. - Step 5.
Verify the same code one step later, first with no window and then with a window of one.
node -e "const {totp,verify}=require('./totp.js'); const S='JBSWY3DPEHPK3PXP'; const t=Math.floor(Date.now()/1000); const code=totp(S,{t}); console.log('code under test ', code); console.log('same step, window 0', verify(S,code,{t,window:0})); console.log('next step, window 0', verify(S,code,{t:t+30,window:0})); console.log('next step, window 1', verify(S,code,{t:t+30,window:1})); console.log('two steps, window 1', verify(S,code,{t:t+60,window:1}));"code under test 253896 same step, window 0 -0 next step, window 0 null next step, window 1 -1 two steps, window 1 null-1is the offset that matched, so the server accepted a code from the previous step.-0is JavaScript negative zero, printed because the loop starts at-window, and it compares equal to0. - Step 6.
Hold the code still and move the server clock instead. This is the drift test.
node -e "const {totp,verify}=require('./totp.js'); const S='JBSWY3DPEHPK3PXP'; const t=Math.floor(Date.now()/1000/30)*30+5; const code=totp(S,{t}); for (const skew of [-90,-60,-30,0,30,60,90]) { const w=verify(S,code,{t:t+skew,window:1}); console.log('server clock '+String(skew).padStart(4)+' s -> '+(w===null?'rejected':'accepted, offset '+(w>0?'+':'')+w)); }"server clock -90 s -> rejected server clock -60 s -> rejected server clock -30 s -> accepted, offset +1 server clock 0 s -> accepted, offset 0 server clock 30 s -> accepted, offset -1 server clock 60 s -> rejected server clock 90 s -> rejectedThree steps accept, four reject. The accepting band is 90 seconds wide, and a server 60 seconds out refuses every user at once.
- Step 7.
Feed the secret in the spaced form the enrolment screen displays, then in the form the decoder expects.
node -e "const {totp,base32}=require('./totp.js'); const t=1789160000; console.log('as the app shows it \"JBSW Y3DP EHPK 3PXP\" ->', totp('JBSW Y3DP EHPK 3PXP',{t}), base32('JBSW Y3DP EHPK 3PXP').toString('hex')); console.log('with the spaces gone \"JBSWY3DPEHPK3PXP\" ->', totp('JBSWY3DPEHPK3PXP',{t}), base32('JBSWY3DPEHPK3PXP').toString('hex'));"as the app shows it "JBSW Y3DP EHPK 3PXP" -> 102201 486530e36378b877a8007d with the spaces gone "JBSWY3DPEHPK3PXP" -> 477286 48656c6c6f21deadbeefTwo different keys, two valid-looking codes, no error. The correct key decodes to
Hello!followed bydeadbeef. Strip spaces before you decode.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Your generator matches Appendix B | The algorithm, truncation and base32 are correct | Move on to the server. Failures from here are the server's. |
| Generated code accepted, app code refused | The app and the server disagree on period, digits or algorithm | Compare them against the otpauth URI from step 2. |
| Accepted at offset -1 or +1 | The server allows one step either side | Expected. Confirm it is one step and not five. |
| Every code refused, generator verified | Server clock drift past the window, or the wrong stored secret | Ask for the server's T and compare with step 3. |
| A code accepted twice | The server does not spend the counter | Raise it. The two factor page has the replay run. |
| The code changes every second | You are hashing the timestamp, not floor(t / 30) | Fix the counter. The step divides, it does not subtract. |
Common mistakes
Thresholds
What to check next
- How to test two factor authentication: spend the code you just generated against a login and watch what the server does with it.
- How to test account lockout after failed logins: the attempt limit that decides whether guessing six digits is worth an attacker's time.
- How to test login functionality: the first factor has to pass before the second one means anything.
- How to test login with curl: the cookie handling that the code step depends on.
- How to check JWT expiration: the other clock-bound credential on a login path, with the same drift problem.
FAQ
How to check if a TOTP code expired?
Compute floor(unixtime / 30) for the moment the code was made and for now. Equal counters mean the code is current. A difference of one is accepted by most servers, as step 5 shows, and a difference of two is refused.
How to generate a TOTP code for testing?
Run the totp function above against the account's base32 secret. It needs no phone, no service and no network. Check it against the RFC 6238 Appendix B vectors first, which is step 1.
How long is a TOTP code valid?
The step is 30 seconds. A server that allows one step either side accepts a code across a 90 second band, measured in step 6. From the moment the app displays it, that leaves between 30 and 60 seconds of remaining life.
How to test OTP without a phone?
Use the generator. The phone contributes nothing the algorithm does not: the same secret and the same clock give the same digits. Keep one real enrolment in the suite to confirm the otpauth URI parameters from step 2.
Why does the server reject a code the app shows?
Three candidates, in this order: server clock drift, a period or digits mismatch, and a secret stored with its display spaces. Step 6 tests the first, step 2 the second, step 7 the third.
Verified
Verified by Maks Vernynode 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate6 minpublished updated Maks Verny