Using the Web Crypto API to Generate TOTP Codes in JavaScript Without 3rd Party Libraries


The Web Crypto API is, thankfully, nothing to do with scammy cryptocurrencies. Instead, it provides access to powerful cryptographic features which were previously only available in 3rd party tools.

So, is it possible to build a TOTP0 code generator without using any external JS libraries? Yes! And it is (relatively) simple.

Here's the code that I've written. It is slightly verbose and contains a lot of logging so you can see what it is doing. I've annotated it with links to the various specifications so you can see where some of the decisions come from. I've compared the output to several popular TOTP code generators and it appears to match. You probably shouldn't use this in production, and you should audit it thoroughly.

I'm sure there are bugs to be fixed and performance enhancements to be made. Feel free to leave a comment here or on the repo if you spot anything.

I consider this code to be trivial but, if it makes you happy, you may consider it licensed under MIT.

JavaScript JavaScriptasync function generateTOTP( 
    base32Secret = "QWERTY",
    interval = 30,
    length = 6,
    algorithm = "SHA-1" ) {

    //  Are the interval and length valid?
    if ( interval <  1 ) throw new Error( "Interval is too short" );
    if ( length   <  1 ) throw new Error( "Length is too low"     );
    if ( length   > 10 ) throw new Error( "Length is too high"    );

    //  Is the algorithm valid?
    //  https://datatracker.ietf.org/doc/html/rfc6238#section-1.2
    algorithm = algorithm.toUpperCase();
    if ( algorithm.match( "SHA-1|SHA-256|SHA-384|SHA-512" ) == null ) throw new Error( "Algorithm not known" );

    //  Decode the secret
    //  The Base32 Alphabet is specified at https://datatracker.ietf.org/doc/html/rfc4648#section-6
    const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
    let bits = "";

    //  Some secrets are padded with the `=` character. Remove padding.
    //  https://datatracker.ietf.org/doc/html/rfc3548#section-2.2
    base32Secret = base32Secret.replace( /=+$/, "" )

    //  Loop through the trimmed secret
    for ( let char of base32Secret ) {
        //  Ensure the secret's characters are upper case
        const value = alphabet.indexOf( char.toUpperCase() );

        //  If the character doesn't appear in the alphabet.
        if (value === -1) throw new Error( "Invalid Base32 character" );

        //  Binary representation of where the character is in the alphabet
        bits += value.toString( 2 ).padStart( 5, "0" );
    }

    //  Turn the bits into bytes
    let bytes = [];
    //  Loop through the bits, eight at a time
    for ( let i = 0; i < bits.length; i += 8 ) {
        if ( bits.length - i >= 8 ) {
                bytes.push( parseInt( bits.substring( i, i + 8 ), 2 ) );
        }
    }

    //  Turn those bytes into an array
    const decodedSecret = new Uint8Array( bytes );
    console.log( "decodedSecret is " + decodedSecret )

    //  Number of seconds since Unix Epoch
    const timeStamp = Date.now() / 1000;
    console.log( "timeStamp is " + timeStamp )

    //  Number of intervals since Unix Epoch
    //  https://datatracker.ietf.org/doc/html/rfc6238#section-4.2
    const timeCounter = Math.floor( timeStamp / interval );
    console.log( "timeCounter is " + timeCounter )

    //  Number of intervals in hexadecimal
    const timeHex = timeCounter.toString( 16 );
    console.log( "timeHex is " + timeHex )

    //  Left-Pad with 0
    paddedHex = timeHex.toString(2).padStart( 16, "0" );
    console.log( "paddedHex is " + paddedHex )

    //  Set up a buffer to hold the data
    const timeBuffer = new ArrayBuffer( 8 );
    const timeView   = new DataView( timeBuffer );

    //  Take the hex string, split it into 2-character chunks
    const timeBytes = paddedHex.match( /.{1,2}/g ).map(
        //  Convert to bytes
        byte => parseInt( byte, 16 )
    );

    //  Write each byte into timeBuffer.
    for ( let i = 0; i < 8; i++ ) {
         timeView.setUint8(i, timeBytes[i]);
    }
    console.log( "timeView is ",  new Uint8Array( timeView   ) );
    console.log( "timeBuffer is", new Uint8Array( timeBuffer ) );

    //  Use Web Crypto API to generate the HMAC key
    //  https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey
    const key = await crypto.subtle.importKey(
        "raw",
        decodedSecret,
        {
            name: "HMAC",
            hash: algorithm
        },
        false,
        ["sign"]
    );

    //  Sign the timeBuffer with the generated HMAC key
    //  https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/sign
    const signature = await crypto.subtle.sign( "HMAC", key, timeBuffer );

    //  Get HMAC as bytes
    const hmac = new Uint8Array( signature );
    console.log( "hmac is ", hmac );

    //  https://datatracker.ietf.org/doc/html/rfc4226#section-5.4
    //  Use the last byte to generate the offset
    const offset = hmac[ hmac.length - 1 ] & 0x0f;
    console.log( "offset is " + offset )

    //  Bit Twiddling operations
    const binaryCode =
        ( ( hmac[ offset     ] & 0x7f ) << 24 ) |
        ( ( hmac[ offset + 1 ] & 0xff ) << 16 ) |
        ( ( hmac[ offset + 2 ] & 0xff ) <<  8 ) |
        ( ( hmac[ offset + 3 ] & 0xff ) );

    //  Turn the binary code into a decimal string
    stringOTP = binaryCode.toString();
    console.log( "stringOTP is " + stringOTP );

    //  Count backwards from the last character for the length of the code
    otp = stringOTP.slice( -length)
    console.log( "otp is " + otp );
    //  Pad with 0 to full length
    otp = otp.padStart( length, "0" );
    console.log( "padded otp is " + otp );

    //  All done!
    return otp;
}


// Generate a TOTP code
( async () => {
    console.log( await generateTOTP( "4FCDTLHR446DPFCKUA46UFIAYTQIDSZ2", 30, 6, "SHA-1" ) );
} )();

It works with the three specified algorithms, generating between 1 and 10 digits, and works with any positive integer interval. Not all combinations are sensible; a one digit code valid for two minutes would be silly. But it is up to you to use this responsibly.

I hope I've shown that you don't need to rely on 3rd party libraries to do your cryptography; you can do it all in the browser instead.

You can grab the code from Codeberg.


  1. *sigh* Please don't be the boring dolt who makes a joke about Top of The Pops. Yes, I know they share the same initialism. And, yes, it's funny how nonce means something different in cryptography compared to British English. ↩︎


Share this post on…

  • Mastodon
  • Facebook
  • LinkedIn
  • BlueSky
  • Threads
  • Reddit
  • HackerNews
  • Lobsters
  • WhatsApp
  • Telegram

2 thoughts on “Using the Web Crypto API to Generate TOTP Codes in JavaScript Without 3rd Party Libraries”

  1. says:

    In the original code, when creating paddedHex from timeHex, the use of toString(2) seems unusual. Since timeHex is already a string, String.toString with a parameter is unnecessary (the parameter 2 is ignored). It's likely safe to remove the toString(2) call entirely. ```diff - paddedHex = timeHex.toString(2).padStart( 16, "0" ); + paddedHex = timeHex.padStart( 16, "0" ); ```
    Reply

What are your reckons?

All comments are moderated and may not be published immediately. Your email address will not be published.

Allowed HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong> <p> <pre> <br> <img src="" alt="" title="" srcset="">