# Secure Credential Storage and Token Refresh in gogcli: A Complete Guide

> Learn about secure credential storage and token refresh in gogcli. Discover how gogcli uses OS-native keyrings and automatic token validation for enhanced security.

- Repository: [Peter Steinberger/gogcli](https://github.com/steipete/gogcli)
- Tags: how-to-guide
- Published: 2026-02-16

---

**gogcli stores OAuth refresh tokens in the OS-native keyring using the 99designs/keyring library, with automatic backend selection and token validation via `CheckRefreshToken` before API calls.**

The `steipete/gogcli` tool handles Google OAuth authentication for CLI workflows, requiring robust secure credential storage and token refresh in gogcli to protect sensitive refresh tokens across macOS, Linux, and headless environments. This architecture ensures that long-lived OAuth credentials never reside in plain text files unless explicitly configured for file-based storage.

## How gogcli Secures OAuth Credentials

At the core of the credential management system is the `secrets.Store` interface defined in `internal/secrets/store.go:18`. This abstraction decouples the application logic from specific storage implementations, allowing gogcli to adapt to different operating system capabilities without code changes.

The concrete implementation, `KeyringStore` (`internal/secrets/store.go:28`), wraps the `github.com/99designs/keyring` library to provide OS-native encryption. This approach leverages macOS Keychain, Linux Secret Service (D-Bus), or Windows credential stores when available, falling back to an encrypted file backend only when necessary.

## Backend Selection and Cross-Platform Support

The `ResolveKeyringBackendInfo` function (`internal/secrets/store.go:77-94`) determines which keyring backend to instantiate through a prioritized configuration hierarchy:

1. **Environment variable**: `GOG_KEYRING_BACKEND` overrides all other settings
2. **Configuration file**: `config.KeyringBackend` specifies the backend in settings
3. **Automatic detection**: Defaults to "auto" to select the best available native backend

For Linux deployments, the `openKeyring` function (`internal/secrets/store.go:59-87`) includes critical headless support. When the `DBUS_SESSION_BUS_ADDRESS` environment variable is missing, the code forces the **file** backend via `shouldForceFileBackend` (lines 51-53) to prevent hanging on an unavailable Secret Service. This ensures reliable operation in Docker containers and SSH sessions.

## Storing and Retrieving Tokens

Token persistence follows a strict serialization protocol to prevent credential leakage. The `SetToken` method (`internal/secrets/store.go:11-50`) handles storage:

- Normalizes email addresses and client names to lowercase
- Validates that refresh tokens are non-empty before storage
- Marshals a `storedToken` struct (containing the sensitive refresh token) to JSON
- Stores the payload under the deterministic key `token:<client>:<email>` (or legacy `token:<email>` for default clients)

The `Token` struct (`internal/secrets/store.go:32-38`) uses the `json:"-"` tag on the `RefreshToken` field to ensure the sensitive value never appears in logs or debug output when the struct is marshalled elsewhere. Only the `storedToken` wrapper includes the refresh token in its JSON representation for encrypted storage.

Retrieval via `GetToken` (`internal/secrets/store.go:53-93`) reverses this process, reading the JSON payload from the keyring and repopulating a `Token` value for application use.

## Token Validation and Refresh

Before executing API calls, gogcli validates stored credentials to detect expired or revoked tokens. The `CheckRefreshToken` function in `internal/googleauth/token_check.go:12-38` performs this validation:

- Constructs an `oauth2.Config` using client credentials read via `readClientCredentials`
- Creates a token source from the stored refresh token using `cfg.TokenSource`
- Attempts to retrieve a fresh access token via the OAuth2 exchange
- Applies a default 15-second timeout to prevent hanging network calls

If the exchange fails, the function returns an error indicating the refresh token is invalid, prompting the user to re-authenticate. This proactive validation prevents cryptic API errors during actual data operations.

For token import workflows, `EmailForRefreshToken` (`internal/googleauth/token_email.go:13-41`) retrieves the authorized email address associated with a raw refresh token. This function uses the `https://www.googleapis.com/auth/userinfo.email` scope to identify the account owner before storing the credential.

## Practical Implementation Examples

### Storing a New Token After Manual OAuth Flow

```go
import (
    "time"
    "github.com/steipete/gogcli/internal/secrets"
    "github.com/steipete/gogcli/internal/config"
)

func saveToken(email, refresh string) error {
    // Open the default keyring store
    store, err := secrets.OpenDefault()
    if err != nil {
        return err
    }

    // Build the token object
    tok := secrets.Token{
        Email:        email,
        RefreshToken: refresh,
        Services:     []string{"gmail"},
        Scopes:       []string{"https://www.googleapis.com/auth/gmail.readonly"},
        CreatedAt:    time.Now().UTC(),
    }

    // Persist under the default client name
    return store.SetToken(config.DefaultClientName, email, tok)
}

```

*Key implementation details:* `OpenDefault` initializes the store at `internal/secrets/store.go:49`, while `SetToken` handles the JSON marshalling and keyring storage at lines 11-50.

### Retrieving a Stored Token

```go
func loadToken(email string) (secrets.Token, error) {
    store, err := secrets.OpenDefault()
    if err != nil {
        return secrets.Token{}, err
    }

    return store.GetToken(config.DefaultClientName, email)
}

```

*Key implementation:* `GetToken` unmarshals the JSON payload from the keyring at `internal/secrets/store.go:53-93`.

### Validating a Refresh Token Before Use

```go
import (
    "context"
    "time"
    "github.com/steipete/gogcli/internal/googleauth"
    "github.com/steipete/gogcli/internal/config"
)

func validate(email string) error {
    // Load the token first
    tok, err := loadToken(email)
    if err != nil {
        return err
    }

    scopes := []string{"https://www.googleapis.com/auth/gmail.readonly"}
    
    // Validate with 15s timeout
    return googleauth.CheckRefreshToken(
        context.Background(),
        config.DefaultClientName,
        tok.RefreshToken,
        scopes,
        15*time.Second,
    )
}

```

*Key implementation:* `CheckRefreshToken` performs the OAuth2 exchange at `internal/googleauth/token_check.go:12-38`.

### Resolving Email from a Raw Refresh Token

```go
func emailFromRefresh(token string) (string, error) {
    scopes := []string{"https://www.googleapis.com/auth/userinfo.email"}
    return googleauth.EmailForRefreshToken(
        context.Background(),
        config.DefaultClientName,
        token,
        scopes,
        15*time.Second,
    )
}

```

*Key implementation:* `EmailForRefreshToken` queries the Google UserInfo endpoint at `internal/googleauth/token_email.go:13-41`.

## Important Files and Architecture

| File | Purpose |
|------|---------|
| [`internal/secrets/store.go`](https://github.com/steipete/gogcli/blob/main/internal/secrets/store.go) | Core keyring wrapper implementing `Store` interface, token CRUD operations, and backend resolution logic |
| [`internal/googleauth/token_check.go`](https://github.com/steipete/gogcli/blob/main/internal/googleauth/token_check.go) | Implements `CheckRefreshToken` for OAuth2 validation and refresh token health checks |
| [`internal/googleauth/token_email.go`](https://github.com/steipete/gogcli/blob/main/internal/googleauth/token_email.go) | Implements `EmailForRefreshToken` to resolve account identity from refresh tokens |
| [`internal/config/paths.go`](https://github.com/steipete/gogcli/blob/main/internal/config/paths.go) | Ensures directory existence for the file-backend keyring via `EnsureKeyringDir` |
| [`internal/config/keys.go`](https://github.com/steipete/gogcli/blob/main/internal/config/keys.go) | Defines `AppName` used as the service label in OS keyring entries |
| [`internal/googleauth/oauth_flow.go`](https://github.com/steipete/gogcli/blob/main/internal/googleauth/oauth_flow.go) | Performs interactive OAuth flows that produce refresh tokens for storage |
| [`cmd/gog/main.go`](https://github.com/steipete/gogcli/blob/main/cmd/gog/main.go) | CLI entry point wiring authentication commands to the storage and validation layers |

These components collectively provide a **secure, cross-platform credential store** that automatically adapts to host capabilities and guarantees that refresh tokens are encrypted at rest and validated before use.

## Summary

- **gogcli** leverages the OS-native keyring via `github.com/99designs/keyring` to encrypt OAuth refresh tokens at rest, selecting macOS Keychain, Linux Secret Service, or encrypted file backends automatically.
- The `KeyringStore` abstraction in [`internal/secrets/store.go`](https://github.com/steipete/gogcli/blob/main/internal/secrets/store.go) provides CRUD operations (`SetToken`, `GetToken`, `DeleteToken`) with deterministic key naming (`token:<client>:<email>`) and JSON serialization that prevents credential leakage in logs.
- Backend selection follows a strict hierarchy: `GOG_KEYRING_BACKEND` environment variable overrides config file settings, with automatic fallback to a file backend when Linux D-Bus is unavailable to prevent hangs in headless environments.
- Token validation occurs proactively via `CheckRefreshToken` in [`internal/googleauth/token_check.go`](https://github.com/steipete/gogcli/blob/main/internal/googleauth/token_check.go), which performs an OAuth2 exchange with a 15-second timeout to verify refresh token validity before API operations.
- The `Token` struct uses `json:"-"` tags to ensure refresh tokens never appear in structured logs, while the `storedToken` wrapper handles encrypted persistence.

## Frequently Asked Questions

### How does gogcli choose which keyring backend to use?

The `ResolveKeyringBackendInfo` function in `internal/secrets/store.go:77-94` implements a three-tier selection process. First, it checks the `GOG_KEYRING_BACKEND` environment variable. If unset, it reads the `KeyringBackend` value from the configuration file. If neither is specified, it defaults to "auto", which triggers automatic detection of the best available native backend (macOS Keychain, Linux Secret Service, or Windows credential store).

### What happens if the Linux Secret Service is unavailable?

When running on Linux without a D-Bus session, the `openKeyring` function (`internal/secrets/store.go:59-87`) detects the missing `DBUS_SESSION_BUS_ADDRESS` environment variable and forces the file backend via `shouldForceFileBackend` (lines 51-53). This prevents the application from hanging indefinitely while waiting for an unavailable Secret Service, ensuring reliable operation in Docker containers, SSH sessions, and other headless Linux environments.

### How does gogcli validate refresh tokens before use?

Before executing API calls, gogcli validates stored credentials using the `CheckRefreshToken` function in `internal/googleauth/token_check.go:12-38`. This function constructs an OAuth2 configuration from client credentials and attempts to exchange the stored refresh token for a fresh access token using `cfg.TokenSource`. The operation includes a 15-second timeout to prevent hanging on network failures. If the exchange fails, the function returns an error indicating the token is invalid or revoked, prompting re-authentication.

### Where are tokens stored when no OS keyring is available?

When native OS keyrings are unavailable or explicitly disabled, gogcli falls back to an encrypted file backend. The `openKeyring` function handles this fallback in `internal/secrets/store.go:59-87`, creating the storage directory via `EnsureKeyringDir` from [`internal/config/paths.go`](https://github.com/steipete/gogcli/blob/main/internal/config/paths.go). The file backend encrypts the token data at rest using the 99designs/keyring library's file implementation, ensuring that even without OS integration, credentials remain encrypted rather than stored in plain text.