- When developing APIs, authentication is inevitable. I use jwt-auth for this purpose.
- A common issue in authentication is token expiration. In the default settings of
config/jwt.php
, the expiration time is one hour. For better security, I’ve set it to 5 minutes. - Forcing users to re-login every 5 minutes would lead to terrible user experience. This is where token refresh functionality becomes essential.
- Normally, we create a dedicated token refresh endpoint. When expired, frontend sends the old token to this endpoint to get a new one.
- For frontend convenience, I implemented backend auto-refresh until final expiration using this approach: Implementing API User Authentication and Painless Token Refresh with Jwt-Auth
- Here’s where the pitfall emerged:
- Authentication-required endpoints with token refresh middleware:
<?php
namespace App\Http\Middleware;
use App\Services\StatusServe;
use Closure;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
class CheckUserLoginAndRefreshToken extends BaseMiddleware
{
/**
* Check user authentication. If token expires,
* refresh token and return in response headers
*/
public function handle($request, Closure $next)
{
$this->checkForToken($request);
try {
if ($this->auth->parseToken()->authenticate()) {
return $next($request);
}
throw new UnauthorizedHttpException('jwt-auth', 'User not found');
} catch (TokenExpiredException $e) {
try {
$token = $this->auth->refresh();
$id = $this->auth
->manager()
->getPayloadFactory()
->buildClaimsCollection()
->toPlainArray()['sub'];
auth()->onceUsingId($id);
} catch (JWTException $e) {
throw new UnauthorizedHttpException('jwt-auth', $e->getMessage(), null, StatusServe::HTTP_PAYMENT_REQUIRED);
}
}
return $this->setAuthenticationHeader($next($request), $token);
}
}
- Optional authentication endpoints using default jwt-auth middleware:
<?php
namespace Tymon\JWTAuth\Http\Middleware;
use Closure;
use Exception;
class Check extends BaseMiddleware
{
/**
* Handle optional authentication
*/
public function handle($request, Closure $next)
{
if ($this->auth->parser()->setRequest($request)->hasToken()) {
try {
$this->auth->parseToken()->authenticate();
} catch (Exception $e) {
// Silent failure for optional auth
}
}
return $next($request);
}
}
- Initially, everything seemed fine until we noticed inconsistent like status display on article lists for logged-in users.
- Debugging revealed that optional endpoints failed to refresh tokens. Users would see correct like status after visiting authenticated pages (which refreshed tokens), but lose it after token expiration.
- The root cause: optional endpoints didn’t handle token refresh. When token expired:
- Article list endpoint (optional auth) → No user data
- Profile page (required auth) → Token refreshed → Article list works again
- Solution: Create custom optional auth middleware with refresh handling:
<?php
namespace App\Http\Middleware;
use Closure;
use Exception;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
class OptionalCheck extends BaseMiddleware
{
public function handle($request, Closure $next)
{
if ($this->auth->parser()->setRequest($request)->hasToken()) {
try {
$this->auth->parseToken()->authenticate();
} catch (TokenExpiredException $e) {
// Add token refresh logic here
// Similar to required auth middleware
return $this->setAuthenticationHeader($next($request), $token);
} catch (Exception $e) {
// Handle other exceptions
}
}
return $next($request);
}
}
Concurrency Consideration
Token refresh race condition scenario:
# Token_1 expires
# Request A comes first, gets new Token_2
# Request B (using Token_1) comes slightly later
# Server blacklists Token_1 after refresh
token_1 returns token_2
A: ------------> server ------------> success
token_1 (now blacklisted)
B: ------------> server ------------> failure
Solution: Set blacklist grace period in jwt.php
:
'blacklist_grace_period' => 5 // 5-second buffer
This allows a short window where expired tokens remain valid after refresh.