The Popup Reward Store API

Token Management

Best practices for storing, refreshing, and managing authentication tokens

Token Management Best Practices

This guide covers production-ready token management strategies for secure and reliable authentication with the API.

Token Expiration Timeline

TokenLifetimeRefresh Strategy
Access Token1 hourRefresh 5-10 minutes before expiry
Refresh Token7 daysRe-authenticate when expired

Token Storage Strategies

For server-to-server integrations, store tokens securely in your backend infrastructure:

# Store tokens in environment variables or secure files
# Never store tokens in log files or version control

# Login and store tokens
RESPONSE=$(curl -s -X POST {{host}}/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "your_api_username",
    "password": "your_api_password"
  }')

# Extract tokens from the response
ACCESS_TOKEN=$(echo $RESPONSE | jq -r '.data.access_token')
REFRESH_TOKEN=$(echo $RESPONSE | jq -r '.data.refresh_token')
EXPIRES_AT=$(echo $RESPONSE | jq -r '.data.access_expires_at')

# Use the access token for API calls
curl -X GET {{host}}/api/v1/products \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json"
<?php
// Option 1: In-memory (simplest, for single-request scripts)
class TokenStorage {
    private $accessToken;
    private $refreshToken;
    private $expiresAt;

    public function storeTokens($tokenData) {
        $this->accessToken = $tokenData['access_token'];
        $this->refreshToken = $tokenData['refresh_token'];
        $this->expiresAt = strtotime($tokenData['access_expires_at']);
    }

    public function getAccessToken() {
        return $this->accessToken;
    }

    public function getRefreshToken() {
        return $this->refreshToken;
    }

    public function shouldRefresh($bufferSeconds = 300) {
        return (time() + $bufferSeconds) >= $this->expiresAt;
    }
}

// Option 2: Redis storage (recommended for long-running applications)
class RedisTokenStorage {
    private $redis;
    private $prefix;

    public function __construct($redis, $clientId) {
        $this->redis = $redis;
        $this->prefix = "api_tokens:{$clientId}";
    }

    public function storeTokens($tokenData) {
        $this->redis->hMSet($this->prefix, [
            'access_token' => $tokenData['access_token'],
            'refresh_token' => $tokenData['refresh_token'],
            'expires_at' => strtotime($tokenData['access_expires_at'])
        ]);

        // Set TTL to match refresh token lifetime (7 days)
        $this->redis->expire($this->prefix, 7 * 24 * 60 * 60);
    }

    public function getAccessToken() {
        return $this->redis->hGet($this->prefix, 'access_token');
    }

    public function getRefreshToken() {
        return $this->redis->hGet($this->prefix, 'refresh_token');
    }

    public function shouldRefresh($bufferSeconds = 300) {
        $expiresAt = (int) $this->redis->hGet($this->prefix, 'expires_at');
        return (time() + $bufferSeconds) >= $expiresAt;
    }
}
?>

Automatic Token Refresh

Implement proactive token refresh to avoid API interruptions. The example below is a self-contained client that handles login, refresh, and automatic retry:

# The access_expires_at field tells you when the token expires
# Parse it and refresh before expiry

# Login and capture token details
RESPONSE=$(curl -s -X POST {{host}}/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "your_api_username",
    "password": "your_api_password"
  }')

ACCESS_TOKEN=$(echo $RESPONSE | jq -r '.data.access_token')
REFRESH_TOKEN=$(echo $RESPONSE | jq -r '.data.refresh_token')
EXPIRES_AT=$(echo $RESPONSE | jq -r '.data.access_expires_at')

# Check if token needs refreshing
EXPIRES_EPOCH=$(date -d "$EXPIRES_AT" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$EXPIRES_AT" +%s)
CURRENT_EPOCH=$(date +%s)
BUFFER=300  # 5 minutes

if [ $((EXPIRES_EPOCH - CURRENT_EPOCH)) -le $BUFFER ]; then
    echo "Token expiring soon, refreshing..."

    REFRESH_RESPONSE=$(curl -s -X POST {{host}}/auth/refresh \
      -H "Content-Type: application/json" \
      -d "{\"refresh_token\": \"$REFRESH_TOKEN\"}")

    # Update stored tokens with new values
    ACCESS_TOKEN=$(echo $REFRESH_RESPONSE | jq -r '.data.access_token')
    REFRESH_TOKEN=$(echo $REFRESH_RESPONSE | jq -r '.data.refresh_token')
    EXPIRES_AT=$(echo $REFRESH_RESPONSE | jq -r '.data.access_expires_at')
fi

# Make API call with valid token
curl -X GET {{host}}/api/v1/products \
  -H "Authorization: Bearer $ACCESS_TOKEN"
<?php
class APIClient {
    private $host;
    private $accessToken;
    private $refreshToken;
    private $accessExpiresAt;
    private $username;
    private $password;

    public function __construct($host, $username, $password) {
        $this->host = $host;
        $this->username = $username;
        $this->password = $password;
    }

    public function authenticate() {
        $data = json_encode([
            'username' => $this->username,
            'password' => $this->password
        ]);

        $ch = curl_init($this->host . '/auth/login');
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($httpCode !== 200) {
            throw new Exception('Authentication failed');
        }

        $result = json_decode($response, true);
        $this->updateTokens($result['data']);
    }

    public function request($url, $method = 'GET', $data = null) {
        $token = $this->getValidAccessToken();

        $headers = [
            'Authorization: Bearer ' . $token,
            'Content-Type: application/json'
        ];

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 30);

        if ($data) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
        }

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        // If we get a 401, try refreshing once and retry
        if ($httpCode === 401) {
            $this->refresh();
            return $this->request($url, $method, $data);
        }

        return json_decode($response, true);
    }

    private function getValidAccessToken() {
        if ($this->shouldRefresh()) {
            $this->refresh();
        }
        return $this->accessToken;
    }

    private function shouldRefresh() {
        if (!$this->accessExpiresAt) return true;
        // Refresh 5 minutes (300 seconds) before expiry
        return (time() + 300) >= $this->accessExpiresAt;
    }

    private function refresh() {
        if (!$this->refreshToken) {
            $this->authenticate();
            return;
        }

        $data = json_encode(['refresh_token' => $this->refreshToken]);

        $ch = curl_init($this->host . '/auth/refresh');
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($httpCode === 200) {
            $result = json_decode($response, true);
            $this->updateTokens($result['data']);
        } else {
            // Refresh failed (expired or revoked), re-authenticate
            $this->authenticate();
        }
    }

    private function updateTokens($tokenData) {
        $this->accessToken = $tokenData['access_token'];
        $this->refreshToken = $tokenData['refresh_token'];
        $this->accessExpiresAt = strtotime($tokenData['access_expires_at']);
    }
}

// Usage
$api = new APIClient('{{host}}', 'your_api_username', 'your_api_password');
$api->authenticate();

// Tokens are automatically refreshed before expiry
$products = $api->request('{{host}}/api/v1/products');
$categories = $api->request('{{host}}/api/v1/categories');
?>

Client-Side Token Validation

You can validate tokens locally by decoding the JWT payload to check expiration without making an API call:

# Decode JWT payload to check expiration locally
# JWT format: header.payload.signature

TOKEN="$ACCESS_TOKEN"
PAYLOAD=$(echo $TOKEN | cut -d'.' -f2)

# Add padding and decode base64
DECODED=$(echo "$PAYLOAD" | base64 -d 2>/dev/null || echo "${PAYLOAD}==" | base64 -d)

echo "Token payload:"
echo $DECODED | jq .

# Check expiration
EXP=$(echo $DECODED | jq -r '.exp')
NOW=$(date +%s)

if [ $NOW -ge $EXP ]; then
    echo "Token is expired"
else
    REMAINING=$((EXP - NOW))
    echo "Token valid for $REMAINING more seconds"
fi
<?php
class TokenValidator {
    public static function isTokenExpired($token) {
        if (empty($token)) return true;

        $parts = explode('.', $token);
        if (count($parts) !== 3) return true;

        try {
            $payload = json_decode(
                base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1])),
                true
            );
            return $payload['exp'] < time();
        } catch (Exception $e) {
            return true;
        }
    }

    public static function getTimeUntilExpiry($token) {
        if (empty($token)) return 0;

        $parts = explode('.', $token);
        if (count($parts) !== 3) return 0;

        try {
            $payload = json_decode(
                base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1])),
                true
            );
            return max(0, $payload['exp'] - time());
        } catch (Exception $e) {
            return 0;
        }
    }

    public static function shouldRefresh($token, $bufferMinutes = 5) {
        $remaining = self::getTimeUntilExpiry($token);
        return $remaining <= ($bufferMinutes * 60);
    }
}

// Usage
if (TokenValidator::shouldRefresh($accessToken)) {
    echo "Token should be refreshed soon\n";
}

$remaining = TokenValidator::getTimeUntilExpiry($accessToken);
echo "Token valid for " . round($remaining / 60) . " more minutes\n";
?>

Error Recovery Patterns

Handling 401 Responses

When an API call returns 401, attempt a token refresh before retrying:

  1. Receive 401 on an API call
  2. Attempt to refresh the token using /auth/refresh
  3. If refresh succeeds, retry the original request with the new token
  4. If refresh fails (401), re-authenticate with /auth/login
  5. If login also fails, surface the error to the caller

Handling 403 Responses

A 403 response indicates the request IP is not in the client's whitelist. This cannot be resolved by refreshing tokens — contact your account administrator to update the IP whitelist.

Best Practices

  1. Track expiration times: Parse access_expires_at from login/refresh responses and refresh proactively
  2. Refresh before expiry: Refresh 5-10 minutes before the access token expires
  3. Handle refresh failures: If the refresh token is also expired, fall back to full re-authentication
  4. Use token rotation: Always store the new refresh token from each refresh response — the old one is revoked
  5. Avoid concurrent refreshes: If multiple threads/processes share tokens, use locking to prevent simultaneous refresh attempts
  6. Store tokens securely: Use encrypted storage (database, Redis, vault) — never log token values
  7. Single retry on 401: Attempt one refresh + retry cycle per request, then re-authenticate

For credential storage and security patterns, see Security Considerations.