diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md
index e9e5b0ad..7b035b80 100644
--- a/CHANGELOG_PENDING.md
+++ b/CHANGELOG_PENDING.md
@@ -3,6 +3,8 @@
 - Fix diagnostic messages when updating environment with invalid definition
   [#422](https://github.com/pulumi/esc/pull/422)
 - Introduce support for rotating static credentials via `fn::rotate` providers [432](https://github.com/pulumi/esc/pull/432)
+- Add the `rotate` CLI command
+  [#433](https://github.com/pulumi/esc/pull/433)
 
 ### Bug Fixes
 
diff --git a/cmd/esc/cli/cli_test.go b/cmd/esc/cli/cli_test.go
index 53ec1a49..2ca47c17 100644
--- a/cmd/esc/cli/cli_test.go
+++ b/cmd/esc/cli/cli_test.go
@@ -677,6 +677,16 @@ func (c *testPulumiClient) OpenEnvironment(
 	return c.openEnvironment(ctx, orgName, envName, env.yaml)
 }
 
+func (c *testPulumiClient) RotateEnvironment(
+	ctx context.Context,
+	orgName string,
+	projectName string,
+	envName string,
+	duration time.Duration,
+) (string, []client.EnvironmentDiagnostic, error) {
+	return c.OpenEnvironment(ctx, orgName, projectName, envName, "", duration)
+}
+
 func (c *testPulumiClient) CheckYAMLEnvironment(
 	ctx context.Context,
 	orgName string,
diff --git a/cmd/esc/cli/client/client.go b/cmd/esc/cli/client/client.go
index 31a87cbf..21d662cd 100644
--- a/cmd/esc/cli/client/client.go
+++ b/cmd/esc/cli/client/client.go
@@ -141,7 +141,7 @@ type Client interface {
 	// DeleteEnvironment deletes the environment envName in org orgName.
 	DeleteEnvironment(ctx context.Context, orgName, projectName, envName string) error
 
-	// OpenEnvironment evaluates the environment envName in org orgName and returns the ID of the opened
+	// OpenEnvironment evaluates the environment projectName/envName in org orgName and returns the ID of the opened
 	// environment. The opened environment will be available for the indicated duration, after which it
 	// will expire.
 	//
@@ -155,6 +155,20 @@ type Client interface {
 		duration time.Duration,
 	) (string, []EnvironmentDiagnostic, error)
 
+	// RotateEnvironment will rotate credentials in an environment.
+	// It also evaluates the environment projectName/envName in org orgName and returns the ID of the opened
+	// environment. The opened environment will be available for the indicated duration, after which it
+	// will expire.
+	//
+	// If the environment contains errors, the open will fail with diagnostics.
+	RotateEnvironment(
+		ctx context.Context,
+		orgName string,
+		projectName string,
+		envName string,
+		duration time.Duration,
+	) (string, []EnvironmentDiagnostic, error)
+
 	// CheckYAMLEnvironment checks the given environment YAML for errors within the context of org orgName.
 	//
 	// This call returns the checked environment's AST, values, schema, and any diagnostics issued by the
@@ -616,6 +630,37 @@ func (pc *client) OpenEnvironment(
 	return resp.ID, nil, nil
 }
 
+func (pc *client) RotateEnvironment(
+	ctx context.Context,
+	orgName string,
+	projectName string,
+	envName string,
+	duration time.Duration,
+) (string, []EnvironmentDiagnostic, error) {
+	path := fmt.Sprintf("/api/esc/environments/%v/%v/%v/rotate", orgName, projectName, envName)
+
+	queryObj := struct {
+		Duration string `url:"duration"`
+	}{
+		Duration: duration.String(),
+	}
+	var resp struct {
+		ID string `json:"id"`
+	}
+	var errResp EnvironmentErrorResponse
+	err := pc.restCallWithOptions(ctx, http.MethodPost, path, queryObj, nil, &resp, httpCallOptions{
+		ErrorResponse: &errResp,
+	})
+	if err != nil {
+		var diags *EnvironmentErrorResponse
+		if errors.As(err, &diags) && diags.Code == http.StatusBadRequest && len(diags.Diagnostics) != 0 {
+			return "", diags.Diagnostics, nil
+		}
+		return "", nil, err
+	}
+	return resp.ID, nil, nil
+}
+
 func (pc *client) CheckYAMLEnvironment(
 	ctx context.Context,
 	orgName string,
diff --git a/cmd/esc/cli/client/client_test.go b/cmd/esc/cli/client/client_test.go
index a5919d26..a833b585 100644
--- a/cmd/esc/cli/client/client_test.go
+++ b/cmd/esc/cli/client/client_test.go
@@ -504,6 +504,76 @@ func TestOpenEnvironment(t *testing.T) {
 	})
 }
 
+func TestRotateEnvironment(t *testing.T) {
+	t.Run("OK", func(t *testing.T) {
+		const expectedID = "open-id"
+		duration := 2 * time.Hour
+
+		client := newTestClient(t, http.MethodPost, "/api/esc/environments/test-org/test-project/test-env/rotate", func(w http.ResponseWriter, r *http.Request) {
+			assert.Equal(t, duration.String(), r.URL.Query().Get("duration"))
+
+			err := json.NewEncoder(w).Encode(map[string]any{"id": expectedID})
+			require.NoError(t, err)
+		})
+
+		id, diags, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", duration)
+		require.NoError(t, err)
+		assert.Equal(t, expectedID, id)
+		assert.Empty(t, diags)
+	})
+
+	t.Run("Diags", func(t *testing.T) {
+		expected := []EnvironmentDiagnostic{
+			{
+				Range: &esc.Range{
+					Environment: "test-env",
+					Begin:       esc.Pos{Line: 42, Column: 1},
+					End:         esc.Pos{Line: 42, Column: 42},
+				},
+				Summary: "diag 1",
+			},
+			{
+				Range: &esc.Range{
+					Environment: "import-env",
+					Begin:       esc.Pos{Line: 1, Column: 2},
+					End:         esc.Pos{Line: 3, Column: 4},
+				},
+				Summary: "diag 2",
+			},
+		}
+
+		client := newTestClient(t, http.MethodPost, "/api/esc/environments/test-org/test-project/test-env/rotate", func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusBadRequest)
+
+			err := json.NewEncoder(w).Encode(EnvironmentErrorResponse{
+				Code:        400,
+				Message:     "bad request",
+				Diagnostics: expected,
+			})
+			require.NoError(t, err)
+		})
+
+		_, diags, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", 2*time.Hour)
+		require.NoError(t, err)
+		assert.Equal(t, expected, diags)
+	})
+
+	t.Run("Not found", func(t *testing.T) {
+		client := newTestClient(t, http.MethodPost, "/api/esc/environments/test-org/test-project/test-env/rotate", func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusNotFound)
+
+			err := json.NewEncoder(w).Encode(apitype.ErrorResponse{
+				Code:    404,
+				Message: "not found",
+			})
+			require.NoError(t, err)
+		})
+
+		_, _, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", 2*time.Hour)
+		assert.ErrorContains(t, err, "not found")
+	})
+}
+
 func TestCheckYAMLEnvironment(t *testing.T) {
 	t.Run("OK", func(t *testing.T) {
 		yaml := []byte(`{"values":{"foo":"bar"}}`)
diff --git a/cmd/esc/cli/env.go b/cmd/esc/cli/env.go
index 63a5f2dd..c6c7a289 100644
--- a/cmd/esc/cli/env.go
+++ b/cmd/esc/cli/env.go
@@ -74,6 +74,7 @@ func newEnvCmd(esc *escCommand) *cobra.Command {
 	cmd.AddCommand(newEnvTagCmd((env)))
 	cmd.AddCommand(newEnvRmCmd(env))
 	cmd.AddCommand(newEnvOpenCmd(env))
+	cmd.AddCommand(newEnvRotateCmd(env))
 	cmd.AddCommand(newEnvRunCmd(env))
 
 	return cmd
diff --git a/cmd/esc/cli/env_open.go b/cmd/esc/cli/env_open.go
index 95112ac8..8c06eeff 100644
--- a/cmd/esc/cli/env_open.go
+++ b/cmd/esc/cli/env_open.go
@@ -40,7 +40,6 @@ func newEnvOpenCmd(envcmd *envCommand) *cobra.Command {
 			if err != nil {
 				return err
 			}
-			_ = args
 
 			var path resource.PropertyPath
 			if len(args) == 1 {
diff --git a/cmd/esc/cli/env_rotate.go b/cmd/esc/cli/env_rotate.go
new file mode 100644
index 00000000..f386c22c
--- /dev/null
+++ b/cmd/esc/cli/env_rotate.go
@@ -0,0 +1,88 @@
+// Copyright 2025, Pulumi Corporation.
+
+package cli
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/pulumi/esc"
+	"github.com/pulumi/esc/cmd/esc/cli/client"
+	"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
+	"github.com/spf13/cobra"
+)
+
+func newEnvRotateCmd(envcmd *envCommand) *cobra.Command {
+	var duration time.Duration
+	var format string
+
+	cmd := &cobra.Command{
+		Use:   "rotate [<org-name>/][<project-name>/]<environment-name>",
+		Short: "Rotate secrets and open the environment",
+		Long: "Rotate secrets and open the environment\n" +
+			"\n" +
+			"This command opens the environment with the given name. The result is written to\n" +
+			"stdout as JSON.\n",
+		SilenceUsage: true,
+		Hidden:       true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := context.Background()
+
+			if err := envcmd.esc.getCachedClient(ctx); err != nil {
+				return err
+			}
+
+			ref, _, err := envcmd.getExistingEnvRef(ctx, args)
+			if err != nil {
+				return err
+			}
+
+			if ref.version != "" {
+				return fmt.Errorf("the rotate command does not accept environments at specific versions")
+			}
+
+			switch format {
+			case "detailed", "json", "yaml", "string", "dotenv", "shell":
+				// OK
+			default:
+				return fmt.Errorf("unknown output format %q", format)
+			}
+
+			env, diags, err := envcmd.rotateEnvironment(ctx, ref, duration)
+			if err != nil {
+				return err
+			}
+			if len(diags) != 0 {
+				return envcmd.writePropertyEnvironmentDiagnostics(envcmd.esc.stderr, diags)
+			}
+
+			return envcmd.renderValue(envcmd.esc.stdout, env, resource.PropertyPath{}, format, false, true)
+		},
+	}
+
+	cmd.Flags().DurationVarP(
+		&duration, "lifetime", "l", 2*time.Hour,
+		"the lifetime of the opened environment in the form HhMm (e.g. 2h, 1h30m, 15m)")
+	cmd.Flags().StringVarP(
+		&format, "format", "f", "json",
+		"the output format to use. May be 'dotenv', 'json', 'yaml', 'detailed', or 'shell'")
+
+	return cmd
+}
+
+func (env *envCommand) rotateEnvironment(
+	ctx context.Context,
+	ref environmentRef,
+	duration time.Duration,
+) (*esc.Environment, []client.EnvironmentDiagnostic, error) {
+	envID, diags, err := env.esc.client.RotateEnvironment(ctx, ref.orgName, ref.projectName, ref.envName, duration)
+	if err != nil {
+		return nil, nil, err
+	}
+	if len(diags) != 0 {
+		return nil, diags, err
+	}
+	open, err := env.esc.client.GetOpenEnvironmentWithProject(ctx, ref.orgName, ref.projectName, ref.envName, envID)
+	return open, nil, err
+}