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

// 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

  1. 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 is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ in 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 65353130

    All six match the SHA-1 column of the Appendix B table. A generator that fails this is testing itself, not the server.

  2. 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=30

    period and digits are 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.

  3. 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  22

    The counter is the number the server hashes. Quote it in the bug report.

  4. 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 171016

    One code covers a whole step, not an instant. The code at t+30 starts with a zero, which is why codes are compared as strings.

  5. 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

    -1 is the offset that matched, so the server accepted a code from the previous step. -0 is JavaScript negative zero, printed because the loop starts at -window, and it compares equal to 0.

  6. 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  ->  rejected

    Three steps accept, four reject. The accepting band is 90 seconds wide, and a server 60 seconds out refuses every user at once.

  7. 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 48656c6c6f21deadbeef

    Two different keys, two valid-looking codes, no error. The correct key decodes to Hello! followed by deadbeef. 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

Sign: A verifier that returns the matched step offset reports success and the caller treats it as a failure.Cause: A match in the current step returns 0, which is falsy. Step 5 prints -0 for exactly this reason. Compare the result against null, never with a truthiness test.
Sign: Codes work all day and fail about one login in twenty.Cause: The code was generated near the end of its step and arrived after the boundary. With a window of 0 this is a real rejection of a correct code. Step 5 reproduces it on demand.
Sign: The secret pasted from the enrolment screen produces a code the server never accepts, with no error anywhere.Cause: Authenticator screens group base32 in fours. A minimal decoder maps the space to index -1 and silently builds a different key. Step 7 prints both keys in hex.
Sign: A stored code fails a string comparison in the test even though the digits look right.Cause: A code with a leading zero, such as 171016 in step 4, loses it once it passes through a number. TOTP output is a fixed-width string, so keep it as one end to end.

Thresholds

30 seconds per step Source: RFC 6238 section 4.1: X represents the time step in seconds (default value X = 30 seconds) and is a system parameter.
one time step of drift Source: RFC 6238 section 5.2: We RECOMMEND that at most one time step is allowed as the network delay.
90 seconds of acceptance Source: Measured in step 6: with a window of one, offsets -30 s, 0 s and +30 s are accepted and 60 s is refused.

What to check next

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.

intermediate6 minpublished updated Maks Verny