Spring Boot Google Sheets API token expires - how to refresh credentials automatically

I built a Spring Boot service that connects to Google Sheets using their Java API. Everything works fine at first, but after a few hours the connection stops working and I get a 401 error.

The issue is that my OAuth token expires and I have to go through the browser login process again. Is there a way to make the token refresh automatically so users don’t have to keep logging in?

Here’s my service code:

import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp;
import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets;
import com.google.api.services.sheets.v4.Sheets;
import com.google.api.services.sheets.v4.SheetsScopes;

@Service
public class SheetsConnectorService implements SheetsConnector {

    private static final Log LOG = LogFactory.getLog(SheetsConnectorService.class);

    @Value("${sheets.app-name}")
    private String appName;

    private static final java.io.File CREDENTIAL_DIR = new java.io.File(System.getProperty("user.home"),
            ".tokens/sheets-api-data");

    private FileDataStoreFactory storeFactory;
    private JsonFactory jsonHandler;
    private HttpTransport transport;
    private List<String> permissions;
    private Sheets sheetClient;

    public SheetsConnectorService() throws Exception {
        try {
            this.jsonHandler = JacksonFactory.getDefaultInstance();
            this.permissions = Arrays.asList(SheetsScopes.SPREADSHEETS);
            this.transport = GoogleNetHttpTransport.newTrustedTransport();
            this.storeFactory = new FileDataStoreFactory(CREDENTIAL_DIR);
        } catch (Exception e) {
            LOG.error("Failed to initialize Sheets connector: " + e.getMessage());
            throw e;
        }

        Credential creds = this.getCredentials();
        this.sheetClient = new Sheets.Builder(this.transport, this.jsonHandler, creds)
                .setApplicationName(this.appName).build();
    }

    public void writeDataToSheet(String docId, String cellRange, ValueRange data) throws IOException {
        Sheets.Spreadsheets.Values.Append writeRequest = sheetClient.spreadsheets().values()
                .append(docId, cellRange, data).setValueInputOption("USER_ENTERED");
        AppendValuesResponse result = writeRequest.execute();
    }

    private Credential getCredentials() throws IOException {
        InputStream secretStream = SheetsConnectorService.class.getResourceAsStream("/config/client_credentials.json");
        GoogleClientSecrets secrets = GoogleClientSecrets.load(this.jsonHandler, new InputStreamReader(secretStream));

        GoogleAuthorizationCodeFlow authFlow = new GoogleAuthorizationCodeFlow.Builder(this.transport, this.jsonHandler,
                secrets, this.permissions).setDataStoreFactory(this.storeFactory).setAccessType("online").build();
        
        LocalServerReceiver.Builder receiverBuilder = new LocalServerReceiver.Builder();
        receiverBuilder.setHost("localhost");
        receiverBuilder.setPort(8888);
        
        Credential userCred = new AuthorizationCodeInstalledApp(authFlow, receiverBuilder.build())
                .authorize("current_user");
        
        LOG.info("Credentials stored at " + CREDENTIAL_DIR.getAbsolutePath());
        return userCred;
    }
}

UPDATE: I fixed it by changing the authorization flow setup like this:

GoogleAuthorizationCodeFlow authFlow = new GoogleAuthorizationCodeFlow.Builder(this.transport, this.jsonHandler,
        secrets, this.permissions).setDataStoreFactory(this.storeFactory).setAccessType("offline")
                .setApprovalPrompt("force")
                .addRefreshListener(
                        new DataStoreCredentialRefreshListener(userId, this.storeFactory))
                .build();

Just hit this same issue last week. Your fix looks good, but wrap that credential refresh in a try-catch and verify the refresh token’s still valid before doing anything with it. Production taught me that FileDataStoreFactory gets cranky with permissions in some deployments. If you’re using containers or cloud environments, switch to a database-backed credential store - file persistence isn’t reliable there. That offline access type change you made? That’s exactly what fixes it.

Nice work with the offline access type. Just watch out for credential refresh in multi-threaded setups. I’ve hit issues where multiple requests try refreshing the token at once and mess things up. Add some synchronization around the refresh logic or use a token cache with proper locking. Also, refresh tokens expire after 6 months of no activity, so you’ll still need a fallback for re-auth.

nice catch on the setAccessType! had the same issue a few months back and completely forgot about that offline param. just a heads up tho - setApprovalPrompt is deprecated now. google wants you to use .setPrompt(“consent”) instead of setApprovalPrompt(“force”). will save u headaches down the road.