YOU ARE (PROBABLY) USING JWT WRONG
JSON Web Token (JWT) is a widely used web standard in various web services1 such as OAuth 2.0 by major platforms. For those unfamiliar, a JWT consists of three parts: the header, payload, and signature. The header specifies the cryptographic algorithm used for the token's signature. The payload contains claims such as issuance timestamp, expiry date, and user authority. Lastly, the signature is a string generated by the specified algorithm using the base64-encoded header and payload. Consequently, if the token is tampered with, the signature becomes invalid.
An example of JWT looks like this, where each part is encoded with base64 and separated by a period:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The header is the first part, decoding to:
{
"alg": "HS256",
"typ": "JWT"
}
The payload is the second part, decoding to:
{
"sub": "123", // user id
"name": "John Doe", // user name
"iat": 1600000000 // issued at
}
The signature is computed as follows:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
MY_SECRET
)
It's quite straightforward! Plus, there are JWT libraries available that handle JWT generation details, so you don't have to worry about them.
Caveats
In practice, if an attacker gains access to a valid JWT, they essentially have the authority of the victim. To mitigate this risk, many implementations aim to reduce the likelihood of JWT theft, even when the token is transmitted over a supposedly secure protocol like HTTPS.
Refresh token
JWT, in its essence, is a stateless authentication method. This means that the server does not need to store any session information about the user. The server can simply verify the token by checking the signature with the secret key, and the payload contains all the necessary information about the user. However, this also means that the server cannot invalidate a token once it is issued. If a JWT is stolen, the attacker can use it until it expires, which can be a long time depending on the expiry time set in the payload. To mitigate this risk, many implementations use a combination of short-lived access tokens and long-lived refresh tokens. The access token is used for authentication and authorization, while the refresh token is used to obtain a new access token when the old one expires.
The refresh token is typically stored securely on the client side, and it is used to obtain a new access token when the old one expires. The refresh token is usually long-lived, meaning it can be used to obtain new access tokens for an extended period of time. This allows the user to remain authenticated without having to log in again. Although it is usually securely stored on the client side, refresh token is exposed to the same risk as the access token. Hence, some servers implement additional security measures such as blacklisting or rotating refresh tokens to mitigate such risks.
Race conditions
However, there's another crucial caveat to consider. The client needs to store the refresh token on the device, and it will be accessed in the future when the access token expires. In many modern HTTP client frameworks such as Retrofit for Android, requests may be made concurrently to reduce perceived loading time.
Ignoring encrypted storage and other factors, what happens if the client makes concurrent requests as the access token is about to expire? In modern HTTP clients, we often encounter filter interfaces for outgoing requests, where we can implement logic to refresh the token when necessary. Note that concurrency deals with multiple tasks being executed at seemingly simultaneous, but different instants.
Consider a scenario where many concurrents requests are made. Each request needs to access the same local storage. When the first request is made, it checks the token and realizes it has expired, triggering a refresh request to the server, ideally through an outgoing request filter. Meanwhile, the subsequent requests will do the same since the token has not been refreshed yet from their point of view.
The issue arises when a finishes refreshing the token first and saves it to the storage. The subsequent requests, unware of the fact that the token has been replaced, sends the refresh request to the server with the old token, resulting in an error and prompting the user to login again. This situation is clearly undesirable in terms of user experience.
Solution
Hence, it is crucial to implement some form of mutex logic, or equivalently some semaphore to lock other concurrent processes when there is already an ongoing refresh request. Fortunately, modern HTTP client frameworks supports interfaces that can intercept in- and outbound requests. In the style of OkHttp3, implementation with mutex using coroutines can be done in the following way:
class AuthenticationInterceptor(private val tokenStorage: TokenStorage) : Interceptor {
private val mutext = Mutex()
private fun refreshToken(): Response {
mutex.withLock {
// implement refreshing logic
...
}
}
override fun intercept(chain: Chain): Response {
// intercept request to add bearer token
val accessToken = tokenStorage.getAccessToken()
val request = chain.request()
if (accessToken.isValid()) {
val newRequest = request
.newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
val response = chain.proceed(newRequest)
return if (response.code() == 401) {
refreshToken()
} else response
} else {
refreshToken()
}
return chain.proceed(request)
}
}