juanfont/headscale · error

saving API key to database: %w

Error message

saving API key to database: %w

What it means

Returned by HSDatabase.CreateAPIKey when GORM's DB.Save(&key) fails while persisting a newly generated API key. The secret has already been bcrypt-hashed, so the error is purely a database write failure: connection loss, constraint violation, SQLite database lock, or insufficient disk/permissions. The generated key string is discarded (empty string returned) because the caller never receives it.

Source

Thrown at hscontrol/db/api_key.go:57

	secret := rands.HexString(apiKeyHashLength)

	// Full key string (shown ONCE to user)
	keyStr := apiKeyPrefix + prefix + "-" + secret

	// bcrypt hash of secret
	hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
	if err != nil {
		return "", nil, err
	}

	key := types.APIKey{
		Prefix:     prefix,
		Hash:       hash,
		Expiration: expiration,
	}

	if err := hsdb.DB.Save(&key).Error; err != nil { //nolint:noinlineerr
		return "", nil, fmt.Errorf("saving API key to database: %w", err)
	}

	return keyStr, &key, nil
}

// ListAPIKeys returns the list of [types.APIKey] values for a user.
func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
	keys := []types.APIKey{}

	err := hsdb.DB.Find(&keys).Error
	if err != nil {
		return nil, err
	}

	return keys, nil
}

// GetAPIKey returns a [types.APIKey] for a given key.

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Check the wrapped error text: 'database is locked' means concurrent SQLite access - stop other writers or move to PostgreSQL.
  2. Verify database connectivity and disk space on the database host.
  3. Confirm the api_keys schema matches the binary version (run migrations / use the same headscale version for CLI and server).
  4. Retry the API key creation; prefixes are random so a unique-index collision is effectively impossible on retry.

Example fix

// before
keyStr, key, err := hsdb.CreateAPIKey(userID, expiration, nil)
if err != nil {
    log.Fatal().Err(err).Msg("failed")
}

// after
keyStr, key, err := hsdb.CreateAPIKey(userID, expiration, nil)
if err != nil {
    if strings.Contains(err.Error(), "database is locked") {
        // retry once after the SQLite writer finishes, or route writes through one process
    }
    return fmt.Errorf("creating API key: %w", err)
}
Defensive patterns

Strategy: retry

Validate before calling

// Verify the database is writable before generating a key
var one int
if err := hsdb.DB.Raw("SELECT 1").Scan(&one).Error; err != nil {
    return fmt.Errorf("database not ready: %w", err)
}

Type guard

func isTransientDBError(err error) bool {
    if err == nil {
        return false
    }
    msg := err.Error()
    return strings.Contains(msg, "database is locked") ||
        strings.Contains(msg, "SQLSTATE 40001") ||
        strings.Contains(msg, "connection refused")
}

Try / catch

keyStr, key, err := hsdb.CreateAPIKey(userID, exp, nil)
if err != nil {
    if isTransientDBError(err) {
        // single retry; prefixes are random so no collision risk
        keyStr, key, err = hsdb.CreateAPIKey(userID, exp, nil)
    }
    if err != nil {
        return fmt.Errorf("creating API key: %w", err)
    }
}

Prevention

When it happens

Trigger: Calling CreateAPIKey (e.g. via 'headscale apikeys create') while the database is locked by another writer (SQLite 'database is locked'), the DB connection has dropped, the api_keys table schema is out of sync with types.APIKey, or the unique index idx_api_keys_prefix collides with an existing prefix.

Common situations: SQLite deployments where the headscale CLI and server run concurrently and contend for the file; Postgres unreachable mid-operation; disk full on the host; a database restored from an older headscale version whose api_keys table lacks columns GORM tries to write.

Related errors


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