Spring Boot Google Sheets API token expires causing 401 error after few hours

I built a Spring Boot service that writes data to Google Sheets using their Java API. Everything works fine initially, but I keep running into an issue where the OAuth token stops working after a few hours and I get 401 unauthorized errors.

The problem is that I have to manually authenticate through the browser again every time this happens. Is there a way to make the token refresh automatically so users don’t have to keep logging in?

Here’s my implementation:

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 SheetsIntegrationService {

    private static final Logger log = LoggerFactory.getLogger(SheetsIntegrationService.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/google-sheets-api");

    private FileDataStoreFactory storeFactory;
    private JsonFactory factory;
    private HttpTransport transport;
    private List<String> permissions;
    private Sheets apiService;

    public SheetsIntegrationService() throws Exception {
        try {
            this.factory = 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 service: " + e.getMessage());
            throw e;
        }

        Credential auth = this.getCredentials();
        this.apiService = new Sheets.Builder(this.transport, this.factory, auth)
                .setApplicationName(this.appName).build();
    }

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

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

        GoogleAuthorizationCodeFlow authFlow = new GoogleAuthorizationCodeFlow.Builder(
                this.transport, this.factory, secrets, this.permissions)
                .setDataStoreFactory(this.storeFactory)
                .setAccessType("online")
                .build();
        
        LocalServerReceiver receiver = new LocalServerReceiver.Builder()
                .setHost("localhost")
                .setPort(8888)
                .build();
                
        Credential auth = new AuthorizationCodeInstalledApp(authFlow, receiver).authorize("default-user");
        log.info("Auth tokens stored in " + CREDENTIAL_DIR.getAbsolutePath());
        return auth;
    }
}

Update: Fixed it by changing the auth flow configuration:

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

Good catch on the offline access fix! Had the same nightmare six months back - client’s reporting system kept dying overnight. Took me forever to realize setApprovalPrompt(“force”) is deprecated. Google wants you to use “auto” now, or just skip it completely since offline access should handle refresh tokens fine by itself. Also - definitely add error handling around the credential refresh. I’ve seen refresh tokens expire when apps sit unused too long. Wrap your API calls in try-catch with a fallback to re-auth when refresh fails. Trust me, it’ll save you from weird failures later.

Heads up - ran into an issue with this pattern. Watch where you store your tokens. FileDataStoreFactory dumps files in that .tokens directory, but if you’re using containers or multiple instances, each one tries to keep its own credential store. Got hit with race conditions where one instance refreshed the token while another used the old one. Switched to a shared database credential store instead of files and that fixed the sync issues. Also, Google’s client library handles the refresh logic automatically once you set up offline access right, so you won’t need custom refresh handling unless you’ve got specific error recovery needs.

btw make sure your credentials.json has the right redirect uri in google console - localhost:8888 needs to match exactly. also seen issues where DataStoreCredentialRefreshListener doesn’t fire if file permissions get screwed up in the .tokens folder. had a prod server that worked fine until we deployed, then boom - permission denied on refresh attempts.