juanfont/headscale · error

saving oauth access token: %w

Error message

saving oauth access token: %w

What it means

CreateAccessToken saves the new token row in a transaction that also verifies the issuing client exists and is unrevoked. This wrapper can therefore carry three distinct failures: a DB error on First (availability), ErrOAuthClientNotFound/ErrOAuthClientRevoked (client deleted or revoked in a race), or a DB error on Save (constraint/connectivity). Unwrap to distinguish; the token string is not returned on failure.

Source

Thrown at hscontrol/db/oauth.go:318

	// Mint inside a transaction that re-checks the client still exists and is
	// not revoked, so a mint cannot complete against a client being deleted.
	err = hsdb.Write(func(tx *gorm.DB) error {
		var client types.OAuthClient

		err := tx.First(&client, "client_id = ?", clientID).Error
		if err != nil {
			return ErrOAuthClientNotFound
		}

		if client.Revoked != nil {
			return ErrOAuthClientRevoked
		}

		return tx.Save(&token).Error
	})
	if err != nil {
		return "", nil, fmt.Errorf("saving oauth access token: %w", err)
	}

	return tokenStr, &token, nil
}

// AuthenticateAccessToken validates a presented bearer token and returns the
// matching, unexpired [types.OAuthAccessToken] (carrying its granted scopes and
// tags). A non-nil error means the token is missing, malformed, or expired.
func (hsdb *HSDatabase) AuthenticateAccessToken(tokenStr string) (*types.OAuthAccessToken, error) {
	if tokenStr == "" {
		return nil, ErrAccessTokenFailedToParse
	}

	_, rest, found := strings.Cut(tokenStr, types.AccessTokenPrefix)
	if !found {
		return nil, ErrAccessTokenFailedToParse
	}

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Unwrap: errors.Is(err, db.ErrOAuthClientNotFound) or ErrOAuthClientRevoked means re-authenticate/re-create the client
  2. For DB-level causes, fix availability and retry minting
  3. Serialize revocation and minting through one admin path

Example fix

// before
_, tok, err := hsdb.CreateAccessToken(clientID, ...)
if err != nil {
	return err
}

// after
_, tok, err := hsdb.CreateAccessToken(clientID, ...)
if errors.Is(err, db.ErrOAuthClientNotFound) || errors.Is(err, db.ErrOAuthClientRevoked) {
	return fmt.Errorf("client %s revoked or deleted; re-create it", clientID)
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Confirm client exists and is unrevoked before minting
if c, err := hsdb.GetOAuthClientByClientID(clientID); err != nil || c.Revoked != nil {
	return fmt.Errorf("client %s unavailable", clientID)
}

Type guard

func isClientStateError(err error) bool {
	return errors.Is(err, db.ErrOAuthClientNotFound) ||
		errors.Is(err, db.ErrOAuthClientRevoked)
}

Try / catch

_, tok, err := hsdb.CreateAccessToken(clientID, ...)
if err != nil {
	if isClientStateError(err) {
		// client revoked mid-flight: surface actionable error
		return errClientRevoked
	}
	if isTransientDBError(err) {
		// safe to retry; token minted only on success
	}
	return err
}

Prevention

When it happens

Trigger: Client revoked between the caller's check and token creation; unique-constraint hit on token prefix (astronomically rare); DB lock/timeout.

Common situations: Concurrent revoke + token mint from different admin sessions; automations caching client state across revocation.

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/db1e8ce18cbc913f. Report an issue: GitHub.