Securing and refreshing your authentication tokens
Sharing the idea of refreshing authentication tokens while speeding up expiration of old ones; using a whitelist inside Redis.
To start off, this is my first article here. I’ve thought about writing it for a while now. Ever since we planned and implemented what we call a moderately secure way to actually authenticate client-side requests against our API where authentication tokens are refreshed and old ones are rapidly expired.
I thought I’d share the idea. I will not be diving into much detail here by using big code examples.
LocalStorage vs. Cookies
We use JSON Web Tokens as authentication tokens. Tokens are generated when the user logs in with his credentials and contains information such as his username (or id). More about the payload below.
This token needs to be stored somewhere on the client side. After doing some research, we came to the conclusion that LocalStorage is not secure as a token storage. As it is a client-side, persistent storage (does not get cleared unless programmatically told to or when the user clears their browser cache), it can be accessed by other sites as well. So it was a no-go to store tokens.
Cookies are considered to be more secure. But only if they contain the proper flags. So of course for us this meant HttpOnly, Secure etc. However, JavaScript does not have an API for HttpOnly cookies. They are not accessible to scripting languages.
So what does this mean? It meant that our API had to handle the authentication cookie.
API to handle HttpOnly cookies
This proved to be great for us. It meant that our front-end does not have to handle anything cookie related. Why did we not think of this from the start?
The client just has to call an endpoint with the username and password, and if it gets a successful response (200 OK), you know the cookie exists and your subsequent requests can be authenticated.
So the cookie has to be set in the API’s response.
Simple PHP example of a response cookie using PSR-7 response:
We use PHP as our back-end language and Slim Framework for our REST API. To handle cookies, we installed FIG Cookies as PSR-7 compliant cookie manager. A cookie inserted in the response, from your controller, looks like this:
$response = FigResponseCookies::set($response,
SetCookie::create('token')
->withValue($jwt) # Authentication token
->withDomain('example.com')
->withPath('/')
->withExpires(time() + (60 * 45))
->withSecure(true)
->withHttpOnly(true)
->withSameSite(SameSite::strict())
);
So, when the client makes a POST call to /user/login
, we first check if the username and password are valid and if they are, we return a response with the cookie in it. Very simple.
JSON Web Tokens
Now, the cookie contains a token. I won’t go into detail about JWT’s. What I will explain, though, is that they contain a payload with information such as your username, and an expiration time. The payload is in JSON format.
{
"username": "john.doe@example.com"
"exp": 1516239022
}
Payload
Now, your payload is publicly readable. You can test tokens at jwt.io if you want. Just paste the generated token there and read it’s payload. Because to this, you cannot store sensitive data inside your payload! What we have there is just the username and expiration time.
Expiration
We set our token (not just cookie) expiration to 45 minutes. Enough for you to log in, maybe grab lunch, get back to using the app. Some people consider this to be too long. To an attacker, who somehow gains access to your token, this is enough time to actually log in imposing as you and start reading your data. I will return to this further down.
Refreshing tokens
So the token is only valid for 45 minutes there. After that the user gets logged out. That’s not gonna work. We need to refresh the token with each valid request (middleware does the signature validation).
We created an afterware to refresh the token. This afterware gets the token from the request, modifies the token by taking its payload, refreshing the expiration property, and returning a new token in the response, inside the same cookie. So now you have a new, refreshed, token with each request you make.
Here is an example of such an afterware:
<?php
namespace App\Middleware;
use Dflydev\FigCookies\FigResponseCookies;
use Dflydev\FigCookies\SetCookie;
use Firebase\JWT\JWT;
use Slim\Http\Request;
use Slim\Http\Response;
final class RefreshTokenExpirationMiddleware {
public function __invoke (Request $request, Response $response, $next) {
# Rewind to get response
$response = $next($request, $response);
$response->getBody()->rewind();
# Pick up the current token (already decoded by the middleware)
$token = $request->getAttribute('auth_token');
if($token) {
# Add 45 minutes to token
$token['exp'] = time() + (60 * 45);
# New token
$encoded = JWT::encode($token, 'secret', ['HS256']);
$modify = function(SetCookie $setCookie) use ($encoded) {
return $setCookie->withValue($encoded)->withExpires(time() + (60 * 45));
};
$response = FigResponseCookies::modify($response, 'auth_token', $modify);
}
return $response;
}
}
Whitelisting tokens
So returning to the expiration; 45 minutes is a big gap. And we are creating more and more tokens with each request! This creates a nice little pile of valid tokens a possible attacker can grab and use. It’s a problem.
So what we did is we found a nice idea online of a blacklist. However, we made it a whitelist.
When the user logs in, and the first token is created, that token gets placed inside a whitelist container that resides in Redis with a TTL (Time to Live) of 45 minutes. A simple storage, but with automatic expiration.
We already had one middleware in place to check if the token’s signature is valid. We then created another middleware that checks if the token is still inside the whitelist. (Sorry, no code examples here, just an explanation).
If the signature is OK and the token is still whitelisted, the request moves forward. Otherwise you get a 401 Unauthorized
response.
Now, after the request has been completed the afterware mentioned earlier takes the token you just made the request with, and rapidly expires it. It gets deleted from the whitelist and then put back in with a 5 min TTL. The newly refreshed token gets inserted there with a 45 min TTL.
With this, you have a valid token with 45 min expiration and any older ones with a 5 min expiration. You just made the window of a possible attack that much more narrow.
Additional security measures
So now, you might be thinking that the 45 minutes is still an open window there. An additional security measure would be to actually log the IP-addresses of each token request and check that the requests originate from the same address subsequently. If a mismatch is detected between requests the user is signed out and forced to log back in.
You can maybe do the same for other information as well. You ever seen those warnings Google sends you when they detect you logged in from a new IP-address or machine? There’s an idea.