Checking API Key without shooting yourself in the foot (JavaScript, NodeJS)
It’s very easy to shoot yourself in the foot, and not only accidentally make it easy for hackers to hack the authentication mechanism, but also make it easier for them to find out the API key!
This can be especially
problematic if the API key controls access to some potentially expensive resource, so by acquiring the API key
the hacker can make you rack up quite a bill.
When a back end developer writes an API, sometimes they need to authenticate the requestor. The server needs to figure out who made the request, so that it can authorize or deny access. There are many ways to accomplish this, and one of them is through an API Key.
How API key-based authentication works
At it’s core, the API Key-based authentication mechiasm works like this:
The APIs consumer sends a request and in it he embeds a key. If the key matches the key the server expects, then the user is authenticated (because they have a shared secret).
Naively, this may look something like this in the code:
if (req.body.apiKey == expectedApiKey) {
// Authorize access
} else {
response.status(401).send('unauthorized');
}
So if the key sent by the consumer (req.body.apiKey
) is the same as the API key we expect (expectedApiKey
) then we authorize access, otherwise we send an error.
Unfortunately this code is vulnerable to a timing attack.
What is a timing attack?
One of the techniques a hacker can use to compromise a system is called a “timing attack”. The way it works is by measuring how long it takes to get a response from the system, and exploit it by deriving insight from the timing.
For example, suppose a hacker is trying to guess a password from a server. If the server takes longer to respond the closer the hacker guesses to the correct password, he can use the timing information to narrow down the possible passwords to guess.
It’s a bit like “Hunt the thimble” where the server is telling the hacker if he’s getting closer (warmer) or farther (colder).
Why exactly is the naive approach vulnerable?
In JavaScript string comparison using the ==
and ===
operators is not timing safe. Under the hood it iterates over the operands and terminates early if a character differs. It would look something like this:
bool operator==(string op1, string op2) {
if(op1.length != op2.length) return false;
for(let i = 0; i < op1.length; ++i) {
if op1[i] != op2[i] return false;
}
return true;
}
There are 2 problems here:
1. If the length of the inputs is not the same, the procedure returns early.
2. The amount of iterations the for
loop goes through depends on the length of the equal substring.
What happens is that it leaks useful information to the attacker: Is the length correct? And if it is, how much of the substring have I guessed correctly so far?
As an attacker, I can guess API keys and measure how long it takes for me to get the rejection message. If I notice that it takes longer to get rejected for some guessed API key than for others, I can deduce that I may have guessed part of it correctly (a substring). I can then take this guess and try various variations on it to narrow down the correct substring or figure out a longer correct substring.
How to fix the vulnerability?
NodeJS has a built-in cryptography module which implements timingSafeEqual. The way it differs from a naive equality check is that it’s based on a constant-time algorithm. You get a response from it after the same amount of time, regardless if the strings are equal or not, and regardless if there’s an equal substring.
It’s code may look something like this:
bool timingSafeEqual(string op1, string op2) {
bool eq = true;
for(let i = 0; i < Math.min(op1.length, op2.length); ++i) {
if op1[i] != op2[i] eq = false;
}
if op1.length != op2.length eq = false;
return eq;
}
By passing req.body.apiKey
and expectedApiKey
as parameters to timingSafeEqual
you find out if they are equal without leaking timing information.
However, it’s not so simple! The documentation for timingSafeEqual
says that the parameters must both be Buffer
s, TypedArray
s, or DataView
s. This isn’t a problem. The problem is that they must have the same byte length.
So we’re in a catch-22 situation. To fix the vulnerability we need to use timingSafeEqual
, but to use timingSafeEqual
we need to do something special if the lengths are not the same. How do we do that without leaking timing information?
Lets look at our options
If we do something like this:
if (crypto.timingSafeEqual(Buffer.from(req.body.apiKey), Buffer.from(expectedApiKey)) {
// Authorize access
} else {
response.status(401).send('unauthorized');
}
an exception will be raised if the lengths are not the same. Even if we handle the exception, it still has a timing footprint so we’ll leak timing information.
If we do something like this:
if (req.body.apiKey.length === expectedApiKey.length && crypto.timingSafeEqual(Buffer.from(req.body.apiKey), Buffer.from(expectedApiKey)) {
// Authorize access
} else {
response.status(401).send('unauthorized');
}
we’re not going to get to the timingSafeEqual
part of the if
statement if the lengths are not the same (because of JavaScript’s Lazy Evaluation), so again we’re leaking timing information.
What we could do is something like this:
if (crypto.timingSafeEqual(Buffer.from(req.body.apiKey.padEnd(expectedApiKey.length).slice(0, expectedApiKey.length)), Buffer.from(expectedApiKey)) {
// Authorize access
} else {
response.status(401).send('unauthorized');
}
which forces the left operand to be the same length as the right operand by padding it. It may be a viable solution, but:
- It looks unclean. It can raise a few eyebrows in code review, and perhaps warrant a “WTF”.
- I’m concerned about the implementation of
String.slice
. Is it a constant-time algorithm? Or does it depend on input size?
I haven’t looked inside NodeJS to verify.
My proposed solution
Hash both and timingSafeEqual
the ciphers.
const hash = crypto.createHash('sha512');
if (crypto.timingSafeEqual(
hash.copy().update(req.body.apiKey).digest(),
hash.copy().update(expectedApiKey).digest()
)) {
// Authorize access
} else {
response.status(401).send('unauthorized');
}
There are some properties of good hashing functions that we use:
- For each pair of invocations to the function, if the plain text is the same then the cipher text is the same.
This way we can compare the ciphers, and know that the plaintext is the same iff the ciphers are the same. - The output of the function is always of the same size (in bits).
This way both inputs oftimingSafeEqual
are of the same size. - The function does the same amount of work regardless of input.
This means that it doesn’t leak timing information.
Here’s an added benefit: Instead of storing the API Key in plain text in the source code or in the environment variables, you can instead store it’s hash. Even if someone has access to your source code or to the environment variables, they will have a hard time figuring out the API key.
This is because of another property of good hashing functions:
- Given plaintext, it’s computationally cheap to compute it’s matching cipher text.
Given cipher text, it’s computationally prohibitively expensive to compute it’s matching plaintext.