How does MFA work?
Passwords aren't enough to keep your most important accounts secure. As I discussed last week, there are many issues with using passwords to prove someone's identity. Multi-factor authentication (MFA) is one of the many tools that developers have to protect users' accounts from compromise.
MFA comes in many forms–and not all forms of MFA are created equal. Let's take a look at your usual suspects:
SMS
SMS is probably the most ubiquitous, while also being the most flawed MFA solution. The application server generates a code (usually 6 digits) and sends a text to your registered phone number. The user enters the code (maybe with the help of their operating system), the site checks it against the code they generated, and the user is authenticated.
This system works well for the most part, but has some glaring issues:
- Users traveling out of their home country can't access their accounts unless they have international roaming enabled.
- Users can't receive SMS messages in situations where internet is available, but mobile data is not (like old buildings or airplanes).
- SMS is often treated like a second authentication factor, but can be used in lieu of a
password:
- On many sites, you can set up multifactor authentication with SMS, but can also reset your password with a text message.
- If an attacker has control over your phone number, they can change your password with it, then use your phone number as the second factor to sign in - really making it a single authentication factor.
- SMS inherently relies on 3rd party infrastructure to deliver these authentication codes.
- There have been many notable cases of attackers using social engineering to gain control over a target's phone number.
- Since cell service providers have ultimate control over your phone number, there's not much you can do as an individual to harden your phone number against compromise.
Email MFA works rather similarly to SMS MFA: the authentication server generates a code, sends it to the registered email on your account, and waits for you to enter the code (or perhaps click a link) before granting access to your account.
Email is generally less flawed than SMS: users have the option to control their own email infrastructure if they desire (and at the very least there are more public choices of email providers than there are cell service providers). Typically, if you're trying to authenticate against a web service, you'll also have access to email, and there's no geographic restriction on email: no need for an expensive roaming plan while abroad.
I've also seen more clever anti-phishing measures in emails, such as requiring users to click a link on the device they're attempting to sign in from. Systems like this may be less convenient, but it reduces the risk of users sharing their 6-digit codes with someone impersonating a service provider. You typically don't see schemes like this in SMS-based MFA because any device with a web browser can access email, but not all devices can access text messages.
Email does suffer the same problem as SMS when it comes to password reset attacks: nearly all web services will allow you to reset your password using a link sent to your email, with little extra verification beyond "does this user have access to the associated email account." I consider this less of a problem than SMS as there are more security features available for email accounts (like strong MFA with WebAuthn) and less possibility of social engineering (have you ever tried to contact Google for support?).
Time-based One Time Password (TOTP)
TOTP builds on the solutions introduced above by using an open algorithm to generate codes for you. Rather than receiving an email with a code for your account, you get a "seed" when setting up MFA. This seed, when input into any TOTP app, will generate 6-digit codes based on what time it is.
TOTP has many advantages: it works entirely offline (your authenticator could be a device without internet access entirely), doesn't rely on vulnerable infrastructure, and is portable. If you change your phone number or email address, you can't receive MFA codes send to them anymore. If you get a new phone or choose to use a new MFA app, you can simply export the seeds for your accounts and load them into your new app.
Let's take a closer look at how MFA works.
It all starts with a secret. This is a random value generated by the server. I generated one for you in the browser and have saved it in LocalStorage:
Hmm... that's not what my server sees. These random values are actually usually generated as sequences of integers:
Unfortunately, converting these numbers to base32 is more involved than converting to hex (note that the number of uints, 0, is not a multiple of the length of the base32 string, 0). A base32 character represents 5 bits of data, while each number is an unsigned 8-bit integer. Let's convert these integers to binary to see what they represent in base32:
Close, but not quite useful yet. 8-bit chunks are useful when working with ints, but less so when working with base32. Since each character in base32 represents a 5-bit chunk, let's switch from 8-bit to 5-bit.
There aren't many rules about how long or short the secret is, but after sampling a few of my MFA-protected accounts, I've seen anything from 10-20 bytes, resulting in 16-32 character base32 strings.
Now that we have a secret, you can add it to an authenticator app. The QR code includes the secret, along with other metadata about the MFA codes.
I suppose you'll need an authenticator to test this out. You can use an app you already have (Google Authenticator, Authy, etc.), or use the one I built for this page:
Set up MFA for ZomboCom
The registration process really doesn't involve much more than generating the secret described above, at least for the server. The reason you have to enter a code from your authenticator is to prove that your authenticator is set up correctly - nothing more.
The registration QR code uses an otpauth://totp/
URI, including data like the secret,
domain name, friendly name of the website, and your username. All of the supporting data is optional,
but helps your authenticator display what the code is used for. It can also specify a number of
digits (default: 6) and duration (default: 30 seconds) for each code, but these properties may
not be supported by all authenticators so they're usually left as the default.
You may have noticed that you can sometimes use codes that are already expired. This is expected behavior: since TOTP codes are based on your local system clock, it's normal for your clock to be slightly off compared to the server (and it's also annoying to have an exactly 30-second window to enter the code). Servers compensate by allowing a small window of codes to work, rather than just one code. There's not a requirement for the window size, but the RFC recommends using a window size of 1, which is what this site is configured to do.
Let's look at this time window more visually:
As time progresses (every 30 seconds under this configuration), codes progress along the lifecycle track. You can use the current valid code 000000, or you can use the just-expired code 000000. You can also use the upcoming code 000000 in case your device's clock is running a little fast compared to the server.
Now that we know how the secret key is generated, let's look at how this is turned into a code. As noted above, this all depends on the current time–specifically the unix epoch timestamp:
This timestamp represents the number of seconds elapsed since the beginning of time, 1970-01-01, in UTC. Since each code we generate is valid for 30 seconds, we can convert this into a counter by dividing it by 30 and dropping the decimal:
Alright, now we have the inputs we need. The HOTP algorithm is defined as:
HOTP? I thought we were talking about TOTP!
TOTP is an implementation of HOTP, which stands for "HMAC-based One Time Password." TOTP is
just a usage of HOTP, using the counter we created above as C
and the secret key
as K
.
First thing's first, we need to implement HMAC-SHA-1
. To do this, we first need
to define some values:
// HMAC_BYTES is the block size of our hash function, SHA-1
const HMAC_BYTES = 64;
// ipad is the byte 0x36 repeated HMAC_BYTES times
const HMAC_IPAD = new Array(HMAC_BYTES).fill(0x36);
// opad is the byte 0x5C repeated HMAC_BYTES times
const HMAC_OPAD = new Array(HMAC_BYTES).fill(0x5C);
const secretKey = "";
const counterBytes = numToUint8Array(57811908) // [0,0,0,0,3,114,35,196]
ipad
and opad
are constant values specified in the RFC for HOTP. By
XORing these values against the actual secretKey (which we do in the following steps), we are
deriving two separate keys to prevent hash collision attacks. The actual values of these
variables is mostly irrelevant–the important part is that they're different. More information
on this in the
security proof (pdf
warning).
Now we need to pad the key up to our block size HMAC_BYTES
:
const keyBytes = base32ToUint8(secretKey); // []
const paddedKey = new Uint8Array(HMAC_BYTES); // [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
paddedKey.set(keyBytes); // [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
Next, XOR paddedKey
with ipad
.
const inner = new Uint8Array(HMAC_BYTES);
for (let i = 0; i < HMAC_BYTES; ++i) {
inner[i] = paddedKey[i] ^ HMAC_IPAD[i]
}
// -> [54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54]
Now we append the stream of bytes representing our counter (generated in our definitions step) to the result of the XOR step.
const innerWithCounter = new Uint8Array(HMAC_BYTES + counterBytes.length);
innerWithCounter.set(inner);
innerWithCounter.set(counterBytes, inner.length);
// -> [54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,0,0,0,0,3,114,35,196]
Then we take a SHA-1 hash of those bytes.
const innerHash = new Uint8Array(await window.crypto.subtle.digest('SHA-1', innerWithCounter));
// -> undefined (converted to hex for readability)
Going back to HMAC_OPAD for a moment, we need to XOR the key with the OPAD.
const outer = new Uint8Array(HMAC_BYTES);
for (let i = 0; i < HMAC_BYTES; ++i) {
outer[i] = paddedKey[i] ^ HMAC_OPAD[i];
}
// -> [92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92]
Then, append the innerHash to the XOR'd stream.
const outerWithHash = new Uint8Array(HMAC_BYTES + innerHash.length);
outerWithHash.set(outer);
outerWithHash.set(innerHash, outer.length);
// -> [92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92,92]
Finally, take the SHA-1 hash of that stream and you have a SHA-1 HMAC.
const hmac = await window.crypto.subtle.digest('SHA-1', outerWithHash);
// -> (converted to hex for readability)
The HMAC is where most of the computations happen - truncation is really just to convert the HMAC into something that's actually possible to quickly type into an authentication form. Let's take a look at how the dynamic truncation algorithm is implemented:
// hmacBuffer represents the value returned by HMAC-SHA-1 since subtleCrypto.digest returns an ArrayBuffer
const hmacArray = new Uint8Array(hmacBuffer); // []
// this is pulled straight from the RFC
const offset = hmacArray[19] & 0x0f; // 0
const binCode =
((hmacArray[offset] & 0x7f) << 24) |
((hmacArray[offset + 1] & 0xff) << 16) |
((hmacArray[offset + 2] & 0xff) << 8) |
(hmacArray[offset + 3] & 0xff); // 0
// Codes can be 6-8 digits; replace 6 with the desired length.
return binCode % Math.pow(10, 6); // 0
And there it is! We just converted the current time and secret key into a valid MFA code by hand! If the code generated isn't the correct number of digits, it gets padded with leading 0s to fill the space properly.
WebAuthn
WebAuthn (also known as Passkeys) is another form of MFA (or a replacement for passwords, depending on implementation), and is more secure than any of the code-based solutions discussed here. I won't dwell on this too much since I have another post about how this all works already–if you found this one interesting I'd recommend giving it a look!
Closing thoughts
TOTP has long been one of those things that I vaguely understood the concept of, but never really thought to dive into before writing this post. I find reading RFCs for concepts like this to be enjoyable because they really show the collaborative nature of the internet: these documents are a collaborative effort to develop and are freely available online for anyone to read.
I'd highly recommend reading the RFCs that went into this (and others!) – you can learn a lot about how things work and even try implementing them yourself.
This post took a bit longer to make than I expected, but I'm glad I took the time. I feel like this is my first post where the page really feels "alive," with interactive elements tying everything together. I'm hoping to keep writing posts more similar to this one (maybe with a couple acoustic text-only posts here and there) going forward.
Oh- and if you use SMS as your second authentication factor, you might want to switch to something else :)
Further reading
- RFC 6238 - TOTP: Time-Based One-Time Password Algorithm
- RFC 4226 - HOTP: An HMAC-Based One-Time Password Algorithm
- RFC 2104 - HMAC: Keyed-Hashing for Message Authentication
Some SMS/Email MFA implementations actually use this algorithm under the hood–if you configure your Amazon account for TOTP and SMS, the code texted to you will be the same as the one appearing in your authenticator.
Not all TOTP apps support this feature, and it's arguable that this decreases the security since an attacker could export your data and compromise all of your accounts. I disagree, and think this is a reasonable tradeoff between useability and security. Apps should check that the user attempting to do an export is the device owner (using biometrics, a pin, or a custom password for the app) first, but beyond that the user should be allowed to control their data however they like.
Authy does not allow users to export MFA secrets, but does support multi-device e2ee sync. Google Authenticator allows transferring between instances of the app with a QR code (but you can decode this yourself to get the raw secret strings). 1Password allows you to view your TOTP secrets directly in the app.