Featured image of post A Pitfall When Developing Optional Token-Passing API Interfaces

A Pitfall When Developing Optional Token-Passing API Interfaces

Authentication Issues Encountered in API Development

  1. When developing APIs, authentication is inevitable. I use jwt-auth for this purpose.
  2. 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.
  3. Forcing users to re-login every 5 minutes would lead to terrible user experience. This is where token refresh functionality becomes essential.
  4. Normally, we create a dedicated token refresh endpoint. When expired, frontend sends the old token to this endpoint to get a new one.
  5. 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
  6. 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);
    }
}
  1. Initially, everything seemed fine until we noticed inconsistent like status display on article lists for logged-in users.
  2. 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.
  3. 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
  4. 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.