Skip to main content
Question

PHP SDK refresh token fails early with “Refresh token has expired” while Web Phone Demo stays signed in for weeks

  • December 29, 2025
  • 7 replies
  • 31 views

Hi RingCentral Team,

I’m facing a refresh token issue using the RingCentral PHP SDK (v2.3.1) in a Laravel application. The refresh token call fails with “Refresh token has expired”, even though the access token is still valid and well within its expiration window.

What makes this confusing is that the official RingCentral Web Phone Demo (https://developers.ringcentral.com/demo.html), using the same RingCentral account, same app, and same environment, stays authenticated for weeks without any issues.

🔍 Issue Summary

  • Access token validity: 1 hour

  • Refresh token validity: 7 days (604800 seconds)

  • Observed behavior:

    • Refresh token fails even when attempted within minutes of issuance

    • Error thrown by SDK:

      Refresh token has expired

  • Expected behavior:

    • Refresh token should remain valid for days, not minutes

🧪 Environment Details

Laravel App

  • PHP 8.1

  • Laravel 10.x

  • RingCentral PHP SDK: 2.3.1

  • cURL: 7.68

  • OS: Ubuntu 20.04

  • Environment: Real Number with old developer acoount

Web Phone Demo

  • Same RingCentral app

  • Same credentials

  • Same Sandbox environment

  • Remains signed in for weeks

🔁 Reproduction Steps

  1. Authenticate successfully and receive access + refresh tokens.

  2. Attempt refresh via $platform->refresh():

    • Even before access token expiration

    • Or with forced refresh

  3. SDK throws:
    “Refresh token has expired”

  4. Meanwhile, the Web Phone Demo using the same account continues working normally.

📜 Logs (Excerpt)

  • Refresh token expiration returned by API: 7 days

  • Refresh attempted within minutes

  • SDK still reports refresh token as expired

This suggests either:

  • A PHP SDK issue

  • Token invalidation due to SDK auth state handling

  • Or a difference in how Web SDK vs PHP SDK handles refresh tokens

❓ Questions for the Community

  • Has anyone encountered refresh token expiring prematurely with the PHP SDK?

  • Is there any known issue with SDK 2.3.1?

  • Is calling $platform->auth()->setData() before refresh still the recommended approach?

  • Are refresh tokens invalidated when multiple platform instances are created?

Any insight from RingCentral engineers or fellow developers would be greatly appreciated. I can provide full logs or a minimal reproduction if needed.

Thanks in advance 🙏
Muhammad Faisal Qasim

7 replies

PhongVu
Community Manager
Forum|alt.badge.img
  • Community Manager
  • December 29, 2025

Using the PHP SDK, after authenticating a user successfully, you have to save the tokens object to your session or to a database. Then whenever you need to call an API, retrieve the tokens object, check the access token expiration time, if it’s still valid, use it. If it expired, check the refresh token expiration time, if it’s still valid, use the refresh token to refresh the tokens then save the new tokens object to your session or the database, then use the access token normally.

See this example project at 

https://github.com/PacoVu/authorization-flow-php/blob/main/ringcentral.php

It is an old example project which supports both sandbox and production environments. Pls notice that there is no longer sandbox environment in RingCentral platform.


Hi ​@PhongVu , thank you for the explanation and for sharing the example project.

I understand the OAuth flow and token lifecycle very well, and I’m already following the same pattern you described:

  • Authenticate user successfully

  • Persist the tokens object

  • Check access token expiration before API calls

  • Refresh using refresh token only when required

  • Save the new tokens object after refresh

I’ve reviewed the reference project again, and functionally my flow is the same.
The main difference is that my implementation is Laravel-based and uses both database and session intentionally to rule out encryption/decryption or persistence issues.

🔎 Clarification on Environment / Reference

I’m aware that:

  • The sandbox environment is deprecated

  • I am currently testing against the correct server configured in config('services.ringcentral.server') of old delveoper account

So this does not appear to be a sandbox vs production issue.

✅ Important: I am testing with freshly created tokens

To avoid any confusion:

  • Tokens are newly issued

  • Refresh is attempted within minutes

  • Access token is still valid

  • Refresh token expiration is still far in the future

  • This is not a reused or long-lived refresh token

📌 Authentication Code (unchanged)

 

public function authenticateUser($code, $userId) { try { $redirectUri = config('services.ringcentral.redirect_url'); $this->platform->login([ 'code' => $code, 'redirectUri' => $redirectUri, ]); $authData = $this->platform->auth()->data(); Log::info('RingCentralService Auth Data', $authData); // Save to session (testing purpose) Session::put('ringcentral_auth_data_' . $userId, $authData); $ringCentralUser = RingCentralUser::updateOrCreate( ['user_id' => $userId], [ 'access_token' => encrypt($authData['access_token']), 'refresh_token' => encrypt($authData['refresh_token']), 'token_expires_at' => now()->addSeconds($authData['expires_in']), 'is_active' => true, ] ); return [ 'success' => true, 'user' => $ringCentralUser, ]; } catch (Exception $e) { Log::error('RingCentral Authentication Error: ' . $e->getMessage()); return [ 'success' => false, 'message' => $e->getMessage(), ]; } }

📌 WebPhone Token Flow (unchanged)

 

if ($forceRefresh || now()->isAfter($ringCentralUser->token_expires_at->subMinutes(5))) { Log::info('Token expiring soon or force refresh, refreshing'); $this->ringCentralService->refreshToken($ringCentralUser); }

📌 Refresh Token Code (unchanged)

 

public function refreshToken($ringCentralUser) { try { $authData = Session::get('ringcentral_auth_data_' . $ringCentralUser->user_id); if (!$authData) { throw new Exception('No auth data in session'); } $sdk = new SDK($this->clientId, $this->clientSecret, $this->server); $platform = $sdk->platform(); $platform->auth()->setData([ 'token_type' => 'Bearer', 'access_token' => $authData['access_token'], 'refresh_token' => $authData['refresh_token'], ]); $platform->refresh(); $newAuthData = $platform->auth()->data(); Session::put('ringcentral_auth_data_' . $ringCentralUser->user_id, $newAuthData); $ringCentralUser->update([ 'access_token' => encrypt($newAuthData['access_token']), 'refresh_token' => encrypt($newAuthData['refresh_token']), 'token_expires_at' => now()->addSeconds($newAuthData['expires_in']), ]); return true; } catch (Exception $e) { Log::error('Failed to refresh token', [ 'error' => $e->getMessage() ]); return false; } }

📜 Logs Showing the Issue (real data)

 

[2025-12-29 14:22:09] INFO: Token expiring soon or force refresh [2025-12-29 14:22:09] INFO: Refreshing tokens for RingCentral user [2025-12-29 14:22:09] INFO: Refresh_token from session: U0pDMDFQMTNQ... [2025-12-29 14:22:09] INFO: Calling platform->refresh() [2025-12-29 14:22:09] ERROR: Refresh token has expired

Key points from logs:

  • Refresh attempted within minutes

  • Access token still valid

  • Refresh token expiration time not reached

  • Refresh token length and value are present

  • Fails immediately with Refresh token has expired

❓ Key Question for Clarification

Could you please confirm the following behavior:

If a refresh attempt is made with a refresh token and that attempt fails,
does that refresh token become immediately invalid, even if it was never successfully used before?

This would help clarify whether:

  • A single failed refresh attempt permanently invalidates the token

  • Or if there is another condition (parallel refresh, internal retry, SDK behavior) that could cause this

✅ Summary

  • I understand the OAuth flow and token lifespans

  • I am already following the recommended pattern

  • Tokens are fresh and persisted correctly

  • This appears to be related to refresh token invalidation behavior, not expiration timing

I’d really appreciate clarification on the refresh-token invalidation rules here.

Thanks again for your help.

Muhammad Faisal Qasim


PhongVu
Community Manager
Forum|alt.badge.img
  • Community Manager
  • December 29, 2025

You mentioned about the WebPhone app using the same app client ID. Is the app running all the time during your PHP app test run? If so, can you register a new app with new app client ID for your PHP app? 

To answer your question “If a refresh attempt is made with a refresh token and that attempt failsdoes that refresh token become immediately invalid, even if it was never successfully used before?”

If a refresh token expired, it is no longer valid. It doesn’t matter if you used it before successfully or not. BUT, every time you refresh the token (even when the access token is still valid) you will get a new access token and new refresh token.


@PhongVu  Thanks for the clarification.

Regarding the WebPhone app using the same app client ID:
I just want to clarify that the demo WebPhone app and my PHP app are not logged in at the same time using the same client ID. I was aware this could cause token invalidation issues, so I’ve been careful about that.
To be extra cautious, I’ve also tested using separate RingCentral apps with different client IDs, and I’m still observing the same behavior.

So I don’t believe concurrent logins with the same client ID are the cause in my case.

About the refresh token behavior, I think this is where my confusion still is, and I want to make sure I fully understand the exact rule.

You mentioned:

Every time you refresh the token (even when the access token is still valid), you will get a new access token and a new refresh token.

That part is clear, and my code already stores and replaces both tokens after each refresh.

However, my specific scenario is this:

  • I manually authenticate the user (authorization code flow)

  • I get a fresh access token + refresh token

  • Within minutes (far before any documented refresh token TTL)

  • I force a refresh call

  • The API responds with “Refresh token has expired”

My question is very specific to this case:

👉 If a refresh attempt is made and the API responds with “Refresh token has expired”,
does that failed refresh attempt itself invalidate the refresh token immediately, even though:

  • the token was freshly created minutes ago, and

  • it was never successfully used to refresh before?

Because from the logs and behavior I’m seeing, it looks like once a refresh call fails, that refresh token can never be used again, even if its lifetime should not have expired yet.

To work around this, I’m currently re-authenticating manually each time to rule out:

  • encryption/decryption issues

  • stale tokens

  • concurrent sessions

But I want to confirm whether this is actually necessary.

So my final question is:

  • Should a freshly issued refresh token continue to work for its full lifetime (e.g. days), even if one refresh attempt fails?

  • Or is it expected behavior that once a refresh attempt fails with “refresh token has expired”, the only valid recovery path is full re-authorization, regardless of how recently the token was issued?

Understanding this will help me decide whether I should:

  • keep forcing full re-authentication defensively, or

  • treat this as an abnormal condition that shouldn’t normally happen.

Thanks again for your help — I appreciate the guidance.


PhongVu
Community Manager
Forum|alt.badge.img
  • Community Manager
  • December 30, 2025

I am very sure that there is nothing wrong with the PHP SDK. Now I doubt that the Laravel framework encrypts the tokens and causes the issue.

I just tried this (with JWT but should make no difference) in my test code and it works perfectly.

$rcsdk = new RingCentral\SDK\SDK($RINGCENTRAL_CLIENTID, $RINGCENTRAL_CLIENTSECRET, $RINGCENTRAL_SERVER);
$platform = $rcsdk->platform();

$fileName = "tokens.json";
try{
if (file_exists($fileName) && filesize($fileName) == 0){
print_r ("Login".PHP_EOL);
$platform->login( [ "jwt" => $RC_JWT ] );
$tokens = $platform->auth()->data();
$handle = fopen($fileName, "w");
fwrite($handle, json_encode($tokens, JSON_PRETTY_PRINT));
}else{
print_r ("Reuse tokens".PHP_EOL);
$handle = fopen($fileName, "r");
$tokens = fread($handle, filesize($fileName));
$tokenObj = json_decode($tokens, true);
$platform->auth()->setData($tokenObj);
}

$params = array(
'dateFrom' => '2025-12-01T00:00:00.001Z',
'dateTo' => '2025-12-20T23:59:59.999Z',
'view' => 'Simple'
);
$resp = $platform->get('/account/~/extension/~/call-log', $params);
foreach ($resp->json()->records as $record) {
print_r ("Call type: ".$record->type.PHP_EOL);
}
}catch (ApiException $e) {
print 'Expected HTTP Error: ' . $e->getMessage() . PHP_EOL;
}
fclose($handle);

Can you test it yourself by writing the tokens into a file then read it from the file and reuse it, in stead of put it in the session.


Thanks for the detailed sample and for taking the time to verify this on your side.

I’d like to add more context from my implementation and clarify where I believe the confusion is happening.

1️⃣ About your shared sample – JWT usage clarification

In your sample:

 

$platform->login( [ "jwt" => $RC_JWT ] );

You are using a JWT grant token to obtain OAuth access/refresh tokens.
You are not refreshing a JWT itself.

So just to confirm my understanding (please correct me if I’m wrong):

  • $RC_JWTJWT grant assertion

  • Used once to exchange for:

    • Access token

    • Refresh token

  • All subsequent calls use OAuth access/refresh tokens, not the JWT

That part is clear 👍

2️⃣ My current flow (OAuth, not JWT grant)

I am not using JWT grant, but OAuth authorization flow:

Step 1: Generate OAuth authorization URL via SDK

 

return $this->platform->authUrl([ 'redirectUri' => $redirectUri, 'state' => uniqid(), ]);

Step 2: Exchange code → access + refresh tokens

Tokens are stored encrypted in DB and later reused.

Step 3: Refresh tokens (forced for testing)

I am explicitly forcing refresh to rule out timing issues.

I’ve attached my entire refresh function above with extensive confirmation logs showing:

  • Tokens are decrypted correctly

  • Token lengths match

  • Refresh token used for refresh is identical to login refresh token

  • Server URL is production (not sandbox)

  • No Laravel session dependency (DB-only source of truth)

Despite this, refresh fails with:

 

Refresh token has expired

while the access token is still valid.

3️⃣ This is NOT a Laravel encryption issue

To eliminate Laravel entirely, I tested:

  • Same credentials

  • Same SDK

  • Same flow

  • Standalone PHP script, no Laravel, no encryption

Result is the same:

 

Username/password authentication is deprecated. Please migrate to the JWT grant type. Unauthorized for this grant type (400)

So at this point I’m confident:

  • ❌ Not Laravel

  • ❌ Not encryption

  • ❌ Not session handling

4️⃣ JWT grant type appears unavailable on my account

This is the key blocker.

When attempting JWT grant via SDK:

 

$platform->login([ 'username' => $username, 'password' => $password, 'extension' => 'main' ]);

I get:

 

Username/password authentication is deprecated. Please migrate to the JWT grant type. Unauthorized for this grant type

However:

  • I do not see any option to enable JWT grant in my developer app

  • I am not using sandbox

  • This worked in older developer accounts in the past

So my assumption (please confirm):

The “JWT grant type” mentioned here refers to a specific OAuth grant configuration on the app,
not the fact that access tokens themselves are JWT-formatted.

Is that correct?

5️⃣ My understanding (please validate)

From everything observed so far, I believe:

  1. RingCentral PHP SDK uses OAuth 2.0

  2. Access tokens returned may be JWT-formatted, but that’s unrelated

  3. JWT grant type is a separate OAuth flow

  4. My developer account/app does not have JWT grant enabled

  5. Password grant is now blocked → causing auth & refresh edge cases

If that’s correct, then my questions are:

6️⃣ Questions needing clarification

  1. Is JWT grant type disabled by default on older developer accounts?

  2. Can JWT grant be enabled only by RingCentral support?

  3. Is JWT grant sandbox-only, or available in production?

  4. If JWT grant is unavailable, what is the supported replacement now that password grant is deprecated?

  5. Is it expected behavior that refresh tokens fail early when password grant is deprecated?

7️⃣ Why I believe this is grant-type related (not token handling)

  • Tokens match exactly

  • Refresh token has not reached expiry time

  • Direct HTTP refresh attempt also fails

  • Same behavior outside Laravel

  • SDK behaves correctly when JWT grant is used (as in your example)

That strongly suggests this is account/app grant configuration, not SDK logic.

Thanks again for your patience — I’m trying to migrate fully to the supported auth flow, but I need to understand how JWT grant can be enabled on my account, or what the officially supported alternative is.

Looking forward to your clarification.


PhongVu
Community Manager
Forum|alt.badge.img
  • Community Manager
  • December 31, 2025

For my sample code, it’s easier and shorter to show using the JWT auth flow.

It DOESN’T matter if it’s the JWT flow or code flow. ONCE you get the access token and the refresh token. The refresh token process is the same regardless of what authentication flow!

Again, you make it more complicated by talking about sandbox, about password flow. WHY?

The test code you need to change an run is just this.

if (file_exists($fileName) && filesize($fileName) == 0){
print_r ("Login".PHP_EOL);
// Login with whatever authentication mode.
// Get the tokens and and SAVE it to a file
$tokens = $platform->auth()->data();
$handle = fopen($fileName, "w");
fwrite($handle, json_encode($tokens, JSON_PRETTY_PRINT));
}else{
print_r ("Reuse tokens".PHP_EOL);
$handle = fopen($fileName, "r");
$tokens = fread($handle, filesize($fileName));
$tokenObj = json_decode($tokens, true);
$platform->auth()->setData($tokenObj);
$platform->refresh();
$tokens = $platform->auth()->data();
$handle = fopen($fileName, "w");
fwrite($handle, json_encode($tokens, JSON_PRETTY_PRINT));
}