Repository: gruntwork-io/terragrunt (language: go)

Issue:
Initalizing dependencies fails when state is encrypted
## Describe the bug

When the state of a dependency is encrypted and the dependency is not initialised, any `terragrunt` command in the dependant module fails. 

## Steps To Reproduce

- Create two terraform modules _A_ and _B_.
- B is a dependency of _A_.
- run `terraform apply` in both.
- verify `terragrunt plan` works in _A_.
- `rm -rf .terragrunt-cache` in _B_. 
- verify `terragrunt plan` still works in _A_
- encrypt the state of B and `rm -rf .terraform-cache` again.
- run `terragrunt plan` in _A_.
- _A_ fails with:
  ```sh
  ERROR  Failed to execute "tofu output -json" in ../B/.terragrunt-cache/779213818
  │ Error: Required plugins are not installed
  ```
- un-encrypt state of _B_ and `rm -rf .terraform-cache`.
- verify `terragrunt plan` works again in _A_.


I created a [minimal example repo](https://github.com/ZeitOnline/terragrunt-3495) unfortunately in this minimal setup I can not reproduce the issue.
Any advice what I should look out for in my real production setup, that could make the difference?

## Expected behavior

State encryption does not alter the behaviour of dependencies

## Versions

- Terragrunt version: 0.68.3
- OpenTofu/Terraform version: 1.8.3
- Environment details: MacOS, can reproduce in CI under Ubuntu22.04

If you need further details I am happy to help out.



Discussion / hints:
When running with debug logging I see
```log 
16:59:56.571 STDERR [../B] tofu: Error loading state: Unsupported state file format: This state file is encrypted and can not be read without an encryption configuration
```

The encryption config for _B_ is added via a `generate` block, like in my example. But I notice, that after the failed run `.terragrunt-cache` in _B_ is empty. Is this somehow an issue of order of execution maybe?

I think the main difference between the example and my real environment is the example using local state. 

Indeed!
When using a `remote_state {}` block in my example I can reproduce the issue.

So my guess is remote state is accessed before the `generate` blocks are executed? And therefore the encryption config is missing, if it is defined in a `generate` block.
Hi,
AFAIK, Terragrunt accesses the remote state before running the generate block to check for configuration drifts.

I bypass this was thinking about: 
* try running with `--terragrunt-disable-bucket-update` to see if it will skip checking this part
* extract config generation in an external script and invoke it through `run_cmd`

https://terragrunt.gruntwork.io/docs/reference/cli-options/#terragrunt-disable-bucket-update




Hi @denis256, thanks for the suggestions.

* `--terragrunt-disable-bucket-update` does not help unfortunately.
* I [tried](https://github.com/ZeitOnline/terragrunt-3495/pull/2) using `run_cmd` with a script to generate the config, but this does not work either.

Also, this only happens in such a dependency setup as described above. Without dependencies I can use encryption config from a `generate` block.

So I, personally, would rathe classify this as a bug.
I [tried using](https://github.com/ZeitOnline/terragrunt-3495/pull/1) a `before_hook` instead of the `generate`, yielding the same result
I looked further into the issue.

Here is a complete debug output:

```sh
❯ terragrunt plan --terragrunt-log-level debug --terragrunt-debug
12:36:57.653 DEBUG  Terragrunt Version: 0.68.3
12:36:57.657 INFO   Terragrunt Cache server is listening on 127.0.0.1:53531
12:36:57.658 INFO   Start Terragrunt Cache server
12:36:57.658 DEBUG  Provider cache dir "../../../../.tf-plugin-cache"
12:36:57.664 DEBUG  Did not find any locals block: skipping evaluation.
12:36:57.664 DEBUG  Running command: tofu --version
12:36:57.664 DEBUG  Engine is not enabled, running command directly in .
12:36:57.684 DEBUG  tofu version: 1.8.3
12:36:57.684 DEBUG  Reading Terragrunt config file at ./terragrunt.hcl
12:36:57.685 DEBUG  Did not find any locals block: skipping evaluation.
12:36:57.685 DEBUG  Did not find any locals block: skipping evaluation.
12:36:57.687 DEBUG  [../B] Did not find any locals block: skipping evaluation.
12:36:57.688 DEBUG  [../B] Did not find any locals block: skipping evaluation.
12:36:57.688 DEBUG  [../B] [Partial] Included config ../terragrunt.hcl has strategy shallow merge: merging config in (shallow).
12:36:57.688 DEBUG  [../B] Did not find any locals block: skipping evaluation.
12:36:57.689 DEBUG  [../B] Did not find any locals block: skipping evaluation.
12:36:57.689 DEBUG  [../B] Included config ../terragrunt.hcl has strategy shallow merge: merging config in (shallow) for dependency.
12:36:57.701 DEBUG  [../B] git show-toplevel result:


..

12:36:57.702 DEBUG  [../B] Did not find any locals block: skipping evaluation.
12:36:57.702 DEBUG  [../B] [Partial] Included config ../terragrunt.hcl has strategy shallow merge: merging config in (shallow).
12:36:57.702 DEBUG  Getting output of dependency ../B/terragrunt.hcl for config ./terragrunt.hcl
12:36:57.703 DEBUG  [../B] Did not find any locals block: skipping evaluation.
12:36:57.703 DEBUG  [../B] Did not find any locals block: skipping evaluation.
12:36:57.704 DEBUG  [../B] [Partial] Included config ../terragrunt.hcl has strategy shallow merge: merging config in (shallow).
12:36:57.704 DEBUG  [../B] Did not find any locals block: skipping evaluation.
12:36:57.704 DEBUG  [../B] Did not find any locals block: skipping evaluation.
12:36:57.705 DEBUG  [../B] [Partial] Included config ../terragrunt.hcl has strategy shallow merge: merging config in (shallow).
12:36:57.705 DEBUG  [../B] Did not find any locals block: skipping evaluation.
12:36:57.705 DEBUG  [../B] Did not find any locals block: skipping evaluation.
12:36:57.705 DEBUG  [../B] [Partial] Included config ../terragrunt.hcl has strategy shallow merge: merging config in (shallow).
12:36:57.705 DEBUG  [../B] Detected remote state block with generate config. Resolving dependency by pulling remote state.
12:36:57.705 DEBUG  [../B] Setting dependency working directory to ../B/.terragrunt-cache/932801613
12:36:57.706 DEBUG  [../B] Generated file ../B/.terragrunt-cache/932801613/_backend.tf.
12:36:57.706 DEBUG  [../B] Generated remote state configuration in working dir ../B/.terragrunt-cache/932801613
12:36:57.706 DEBUG  [../B] Copying lock file from ../B/.terraform.lock.hcl to ../B/.terragrunt-cache/932801613
12:36:57.706 DEBUG  [../B] Running command: tofu init -get=false
12:36:57.706 DEBUG  [../B] Engine is not enabled, running command directly in ../B/.terragrunt-cache/932801613
12:36:58.256 DEBUG  [../B] Ignoring expected error from dependency init call
12:36:58.256 DEBUG  [../B] Init call stderr:
12:36:58.256 DEBUG  [../B] 12:36:57.729 STDOUT [../B] tofu: Initializing the backend...
12:36:58.117 STDOUT [../B] tofu:
12:36:58.117 STDOUT [../B] tofu: Successfully configured the backend "gcs"! OpenTofu will automatically
12:36:58.117 STDOUT [../B] tofu: use this backend unless the backend configuration changes.
12:36:58.251 STDERR [../B] tofu: Error loading state: Unsupported state file format: This state file is encrypted and can not be read without an encryption configuration

12:36:58.256 DEBUG  [../B] Running command: tofu output -json
12:36:58.256 DEBUG  [../B] Engine is not enabled, running command directly in ../B/.terragrunt-cache/932801613
╷
│ Error: Required plugins are not installed
│
│ The installed provider plugins are not consistent with the packages
│ selected in the dependency lock file:
│   - registry.opentofu.org/hashicorp/null: there is no package for registry.opentofu.org/hashicorp/null 3.2.3 cached in .terraform/providers
│
│ OpenTofu uses external plugins to integrate with a variety of different
│ infrastructure services. To download the plugins required for this
│ configuration, run:
│   tofu init
╵
12:36:58.296 INFO   Shutting down Terragrunt Cache server...
12:36:58.296 INFO   Terragrunt Cache server stopped
12:36:58.296 ERROR  Failed to execute "tofu output -json" in ../B/.terragrunt-cache/932801613
╷
│ Error: Required plugins are not installed
│
│ The installed provider plugins are not consistent with the packages
│ selected in the dependency lock file:
│   - registry.opentofu.org/hashicorp/null: there is no package for registry.opentofu.org/hashicorp/null 3.2.3 cached in .terraform/providers
│
│ OpenTofu uses external plugins to integrate with a variety of different
│ infrastructure services. To download the plugins required for this
│ configuration, run:
│   tofu init
╵

exit status 1
12:36:58.475 DEBUG  util.ProcessExecutionError Failed to execute "tofu output -json" in ../B/.terragrunt-cache/932801613
╷
│ Error: Required plugins are not installed
│
│ The installed provider plugins are not consistent with the packages
│ selected in the dependency lock file:
│   - registry.opentofu.org/hashicorp/null: there is no package for registry.opentofu.org/hashicorp/null 3.2.3 cached in .terraform/providers
│
│ OpenTofu uses external plugins to integrate with a variety of different
│ infrastructure services. To download the plugins required for this
│ configuration, run:
│   tofu init
╵

exit status 1
/home/circleci/project/shell/run_shell_cmd.go:216 (0x101320371)
/home/circleci/project/telemetry/metrics.go:42 (0x100c76d88)
/home/circleci/project/telemetry/telemetry.go:80 (0x10131f014)
/home/circleci/project/telemetry/traces.go:38 (0x100c78fa8)
/home/circleci/project/telemetry/telemetry.go:79 (0x10131ef2c)
/home/circleci/project/shell/run_shell_cmd.go:111 (0x10131ec99)
/home/circleci/project/shell/run_shell_cmd.go:72 (0x10131eb98)
/home/circleci/project/config/dependency.go:949 (0x10169b35c)
/home/circleci/project/config/dependency.go:781 (0x10169a4b4)
/home/circleci/project/config/dependency.go:616 (0x1016997e4)
/home/circleci/project/config/dependency.go:550 (0x101698fd4)
/home/circleci/project/config/dependency.go:474 (0x101698710)
/home/circleci/project/config/dependency.go:164 (0x1016966a4)
/home/circleci/project/config/dependency.go:414 (0x101698008)
/home/circleci/go/pkg/mod/golang.org/x/sync@v0.8.0/errgroup/errgroup.go:78 (0x1012d9314)
/usr/local/go/src/runtime/asm_arm64.s:1223 (0x1006936e4)


```

So `Generated file ../B/.terragrunt-cache/932801613/_backend.tf.` shows me that the `remote_state` get's templated, and tofu gets executed.
The line
```
Ignoring expected error from dependency init call
```

leads me to this [function](https://github.com/gruntwork-io/terragrunt/blob/09e981367f63984925c5ff8b80533c3a8b02cfbf/config/dependency.go#L1124). 

So terragrunt would have to not only generate the backendconfig, but also run all generate blocks before being sure that the [remote state config is ready](https://github.com/gruntwork-io/terragrunt/blob/09e981367f63984925c5ff8b80533c3a8b02cfbf/config/dependency.go#L933).

Either this, or encryption config could be a part of the `remote_state` block, or even become a block of its own, that is handled [here](https://github.com/gruntwork-io/terragrunt/blob/09e981367f63984925c5ff8b80533c3a8b02cfbf/config/dependency.go#L865C6-L865C44) like `remote_state`.

Digging deeper into the code it seems to me, that `generate` blocks should maybe be added to [config_partial](https://github.com/gruntwork-io/terragrunt/blob/09e981367f63984925c5ff8b80533c3a8b02cfbf/config/config_partial.go#L26), so they can be handled the same as [`remoteStateTGConfig`](https://github.com/gruntwork-io/terragrunt/blob/09e981367f63984925c5ff8b80533c3a8b02cfbf/config/dependency.go#L757) for dependencies.

Since this would implicate a design decision, I would love to hear back from @denis256 or @yhakbar before trying to put together a PR (which may not be successful, for I am not experienced in writing Golang).
Hey @norman-zon ,

Unless you or @denis256 disagree with me, what I think makes the most sense is to have encryption configurations pulled into `remote_state`, and to treat it like a first class citizen.

The reason I would recommend this is that it seems sensible to have [_backend.tf](https://github.com/ZeitOnline/terragrunt-3495/blob/main/terragrunt.hcl#L29) contain all the logic used for state configurations, rather than looking across two files to determine what's happening to state. I would prefer that over adjusting how `generate` works.
I should also point out that there's special logic used for directly parsing dependencies from the backend, rather than going through `tofu output` [here](https://terragrunt.gruntwork.io/docs/reference/cli-options/#terragrunt-fetch-dependency-output-from-state). That won't work for encrypted state, so it seems like `remote_state` will eventually need first-class support of interacting with encrypted state.
Thanks, @yhakbar for having a look at this and giving your opinion. I agree with you that it makes sense, to have all state related config in one place. Using `generate` only came to mind, because the `encryption` block is quite flexible compared to the `backend` block, so my initial thought was so handle it separately. But grouping functionally makes more sense, now that you point it out.  

I would be really happy to see this implemented soon, since we are stuck in the middle of the process of state migration here. 
If you're interested in taking a stab at the implementation, @norman-zon , we'd be happy to help. Otherwise, it might take a little longer for us to get around to this. We have [docs on contributing](https://terragrunt.gruntwork.io/docs/community/contributing/).

Let me know either way, though. It's a really useful feature in OpenTofu, and I'm sure Terragrunt users will benefit from better support.
I will give it a try, but have to tell to you right away, my experiences with Go are very limited.
Nevertheless, let me see if I can get a PR up this week to get things going.
@yhakbar one more design question.

Would you like to have the encryption config as a block inside the `remote_state` block and completely defined, like the `config` block is now. Making it neccesary to consider all different possible key providers (pbkdf2, aws, gcp, openbao) separetely. Something like this:

```hcl
encryption = {
    key_provider = "pbkdf2"
    passphrase   = "PASSPHRASE"
    method          = "aes_gcm"
    ...
}
```

Or would you rather like to have an arbitrary block allowing to write the encryption config directly, without any validation or help from terragrunt that just gets rendered into _backend.tf as is. Like this:

```hcl
encryption = {
  key_provider "pbkdf2" "default" {
    passphrase = "PASSPHRASE"
  }
  method "aes_gcm" "default" {
    keys = key_provider.pbkdf2.default
  }
...
}
```

Or would you even nest the `encryption` block inside the `config` block?
Hey @norman-zon ,

I don't think it's possible to nest a configuration block into an attribute in HCL, but I may be mistaken on that.

If you're not aware of the difference, anything that has an `=` to the right of it is an attribute.

I think the simplest thing to do is likely to have an `encryption` configuration block added to `remote_state`, and to match the OpenTofu schema.

I'm not sure about the context for why we've setup `remote_state` to use attributes instead of configuration blocks, however, so @denis256 or @levkohimins might have different opinions on the best way to tackle this.

Fix patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 96e53573d6..82827a2e6a 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -86,6 +86,14 @@ run_integration_test: &run_integration_test
     run-go-tests --packages "$(go list ./... | grep /test | tr '\n' ' ')" | tee logs/test-results.log
   no_output_timeout: 30m
 
+run_integration_tofu_only_test: &run_integration_tofu_only_test
+  name: Run tofu-only integration tests
+  command: |
+    run-go-tests --packages "-tags tofu -run ^TestTofu ./..." | tee logs/test-results.log
+  no_output_timeout: 30m
+  environment:
+    TERRAGRUNT_TFPATH: tofu
+
 run_integration_gcp_test: &run_integration_gcp_test
   name: Run integration tests GCP
   command: |
@@ -323,6 +331,27 @@ jobs:
       - store_test_results:
           path: logs
 
+  integration_test_tofu_only:
+    resource_class: medium
+    <<: *defaults
+    <<: *env
+    steps:
+      - checkout
+      - run:
+          <<: *install_tofu
+      - run:
+          <<: *install_tflint
+      - run:
+          <<: *setup_test_environment
+      - run:
+          <<: *run_integration_tofu_only_test
+      - run:
+          <<: *run_terratest_log_parser
+      - store_artifacts:
+          path: logs
+      - store_test_results:
+          path: logs
+
   integration_test_tofu_with_provider_cache:
     resource_class: xlarge
     <<: *defaults
@@ -732,6 +761,14 @@ workflows:
             - AWS__PHXDEVOPS__circle-ci-test
             - GCP__automated-tests
             - GITHUB__PAT__gruntwork-ci
+      - integration_test_tofu_only:
+          filters:
+            tags:
+              only: /^v.*/
+          context:
+            - AWS__PHXDEVOPS__circle-ci-test
+            - GCP__automated-tests
+            - GITHUB__PAT__gruntwork-ci
       - integration_test_tofu_with_provider_cache:
           filters:
             tags:
diff --git a/codegen/generate.go b/codegen/generate.go
index c142846d5a..0fdb50ab21 100644
--- a/codegen/generate.go
+++ b/codegen/generate.go
@@ -9,6 +9,7 @@ import (
 	"sort"
 	"strings"
 
+	"github.com/hashicorp/hcl/v2"
 	"github.com/hashicorp/hcl/v2/hclsimple"
 
 	"github.com/hashicorp/hcl/v2/hclwrite"
@@ -60,6 +61,10 @@ const (
 	DisabledRemoveTerragruntStr = "remove_terragrunt"
 
 	assumeRoleConfigKey = "assume_role"
+
+	encryptionKeyProviderKey = "key_provider"
+	encryptionResourceName   = "default"
+	encryptionDefaultMethod  = "aes_gcm"
 )
 
 // GenerateConfig is configuration for generating code
@@ -231,9 +236,10 @@ func fileWasGeneratedByTerragrunt(path string) (bool, error) {
 }
 
 // RemoteStateConfigToTerraformCode converts the arbitrary map that represents a remote state config into HCL code to configure that remote state.
-func RemoteStateConfigToTerraformCode(backend string, config map[string]interface{}) ([]byte, error) {
+func RemoteStateConfigToTerraformCode(backend string, config map[string]interface{}, encryption map[string]interface{}) ([]byte, error) {
 	f := hclwrite.NewEmptyFile()
-	backendBlock := f.Body().AppendNewBlock("terraform", nil).Body().AppendNewBlock("backend", []string{backend})
+	terraformBlock := f.Body().AppendNewBlock("terraform", nil).Body()
+	backendBlock := terraformBlock.AppendNewBlock("backend", []string{backend})
 	backendBlockBody := backendBlock.Body()
 
 	var backendKeys = make([]string, 0, len(config))
@@ -284,6 +290,80 @@ func RemoteStateConfigToTerraformCode(backend string, config map[string]interfac
 		backendBlockBody.SetAttributeValue(key, ctyVal.Value)
 	}
 
+	// encryption can be empty
+	if len(encryption) > 0 {
+		// extract key_provider first to create key_provider block
+		keyProvider, found := encryption[encryptionKeyProviderKey].(string)
+		if !found {
+			return nil, errors.New(encryptionKeyProviderKey + " is mandatory but not found in the encryption map")
+		}
+
+		keyProviderTraversal := hcl.Traversal{
+			hcl.TraverseRoot{Name: encryptionKeyProviderKey},
+			hcl.TraverseAttr{Name: keyProvider},
+			hcl.TraverseAttr{Name: encryptionResourceName},
+		}
+
+		methodTraversal := hcl.Traversal{
+			hcl.TraverseRoot{Name: "method"},
+			hcl.TraverseAttr{Name: encryptionDefaultMethod},
+			hcl.TraverseAttr{Name: encryptionResourceName},
+		}
+
+		// encryption block
+		encryptionBlock := terraformBlock.AppendNewBlock("encryption", nil)
+		encryptionBlockBody := encryptionBlock.Body()
+
+		// Append key_provider block
+		keyProviderBlockBody := encryptionBlockBody.AppendNewBlock(encryptionKeyProviderKey, []string{keyProvider, encryptionResourceName}).Body()
+
+		// Append method block
+		methodBlock := encryptionBlockBody.AppendNewBlock("method", []string{encryptionDefaultMethod, encryptionResourceName}).Body()
+		methodBlock.SetAttributeTraversal("keys", keyProviderTraversal)
+
+		// Append state block
+		stateBlock := encryptionBlockBody.AppendNewBlock("state", nil).Body()
+		stateBlock.SetAttributeTraversal("method", methodTraversal)
+
+		// Append plan block
+		planBlock := encryptionBlockBody.AppendNewBlock("plan", nil).Body()
+		planBlock.SetAttributeTraversal("method", methodTraversal)
+
+		var encryptionKeys = make([]string, 0, len(encryption))
+
+		for key := range encryption {
+			encryptionKeys = append(encryptionKeys, key)
+		}
+
+		sort.Strings(encryptionKeys)
+
+		// Fill key_provider block with ordered attributes
+		for _, key := range encryptionKeys {
+			if key == encryptionKeyProviderKey {
+				continue
+			}
+
+			value, ok := encryption[key]
+			if !ok {
+				continue
+			}
+
+			// Skip basic types with zero values
+			if value == "" || value == 0 {
+				continue
+			}
+
+			ctyVal, err := convertValue(value)
+			if err != nil {
+				return nil, errors.New(err)
+			}
+
+			if keyProviderBlockBody != nil {
+				keyProviderBlockBody.SetAttributeValue(key, ctyVal.Value)
+			}
+		}
+	}
+
 	return f.Bytes(), nil
 }
 
diff --git a/config/config.go b/config/config.go
index deff632a24..bc2d4496d7 100644
--- a/config/config.go
+++ b/config/config.go
@@ -254,6 +254,7 @@ type remoteStateConfigFile struct {
 	DisableDependencyOptimization *bool                      `hcl:"disable_dependency_optimization,attr"`
 	Generate                      *remoteStateConfigGenerate `hcl:"generate,attr"`
 	Config                        cty.Value                  `hcl:"config,attr"`
+	Encryption                    *cty.Value                 `hcl:"encryption,attr"`
 }
 
 func (remoteState *remoteStateConfigFile) String() string {
@@ -280,6 +281,17 @@ func (remoteState *remoteStateConfigFile) toConfig() (*remote.RemoteState, error
 
 	config.Config = remoteStateConfig
 
+	if remoteState.Encryption != nil && !remoteState.Encryption.IsNull() {
+		remoteStateEncryption, err := ParseCtyValueToMap(*remoteState.Encryption)
+		if err != nil {
+			return nil, err
+		}
+
+		config.Encryption = remoteStateEncryption
+	} else {
+		config.Encryption = nil
+	}
+
 	if remoteState.DisableInit != nil {
 		config.DisableInit = *remoteState.DisableInit
 	}
diff --git a/config/config_as_cty.go b/config/config_as_cty.go
index 0f8acfd2d8..c9595a5476 100644
--- a/config/config_as_cty.go
+++ b/config/config_as_cty.go
@@ -599,8 +599,8 @@ func terraformConfigAsCty(config *TerraformConfig) (cty.Value, error) {
 }
 
 // RemoteStateAsCty serializes RemoteState to a cty Value. We can't directly
-// serialize the struct because `config` is an arbitrary
-// interface whose type we do not know, so we have to do a hack to go through json.
+// serialize the struct because `config` and `encryption` are arbitrary
+// interfaces whose type we do not know, so we have to do a hack to go through json.
 func RemoteStateAsCty(remoteState *remote.RemoteState) (cty.Value, error) {
 	if remoteState == nil {
 		return cty.NilVal, nil
@@ -625,6 +625,13 @@ func RemoteStateAsCty(remoteState *remote.RemoteState) (cty.Value, error) {
 
 	output["config"] = ctyJSONVal
 
+	ctyJSONVal, err = convertToCtyWithJSON(remoteState.Encryption)
+	if err != nil {
+		return cty.NilVal, err
+	}
+
+	output["encryption"] = ctyJSONVal
+
 	return convertValuesMapToCtyVal(output)
 }
 
diff --git a/docs/_docs/04_reference/config-blocks-and-attributes.md b/docs/_docs/04_reference/config-blocks-and-attributes.md
index 6191fa23df..85e8782581 100644
--- a/docs/_docs/04_reference/config-blocks-and-attributes.md
+++ b/docs/_docs/04_reference/config-blocks-and-attributes.md
@@ -368,6 +368,9 @@ The `remote_state` block supports the following arguments:
 - `config` (attribute): An arbitrary map that is used to fill in the backend configuration in OpenTofu/Terraform. All the
   properties will automatically be included in the OpenTofu/Terraform backend block (with a few exceptions: see below).
 
+- `encryption` (attribute): A map that is used to configure state and plan encryption in OpenTofu. The properties will be transformed
+  into an `encryption` block in the OpenTofu terraform block. The properties are specific to the respective `key_provider` (see below).
+
   For example, if you had the following `remote_state` block:
 
   ```hcl
@@ -419,6 +422,8 @@ locals {
 remote_state = local.common.remote_state
 ```
 
+#### backend
+
 Note that Terragrunt does special processing of the `config` attribute for the `s3` and `gcs` remote state backends, and
 supports additional keys that are used to configure the automatic initialization feature of Terragrunt.
 
@@ -563,6 +568,56 @@ terraform {
 }
 ```
 
+#### encryption
+
+The encryption map needs a `key_provider` property, which can be set to one of `pbkdf2`, `aws_kms` or `gcp_kms`.
+
+Documentation for each provider type and its possible configuration can be found in the [OpenTofu docs](https://opentofu.org/docs/language/state/encryption/).
+
+A `terragrunt.hcl` file configuring PBKDF2 encryption could look like this:
+
+```hcl
+remote_state {
+  backend = "s3"
+  config = {
+    bucket = "mybucket"
+    key    = "path/to/my/key"
+    region = "us-east-1"
+  }
+
+  encryption = {
+    key_provider = "pbkdf2"
+    passphrase   = get_env("PBKDF2_PASSPHRASE")
+  }
+}
+```
+
+This would result in the following OpenTofu code:
+
+```hcl
+terraform {
+  backend "s3" {
+    bucket = "mybucket"
+    key    = "path/to/my/key"
+    region = "us-east-1"
+  }
+  encryption {
+    key_provider "pbkdf2" "default" {
+      passphrase = "SUPERSECRETPASSPHRASE"
+    }
+    method "aes_gcm" "default" {
+      keys = key_provider.pbkdf2.default
+    }
+    state {
+      method = method.aes_gcm.default
+    }
+    plan {
+      method = method.aes_gcm.default
+    }
+  }
+}
+```
+
 ### include
 
 The `include` block is used to specify inheritance of Terragrunt configuration files. The included config (also called
diff --git a/remote/remote_encryption.go b/remote/remote_encryption.go
new file mode 100644
index 0000000000..7d8a153324
--- /dev/null
+++ b/remote/remote_encryption.go
@@ -0,0 +1,94 @@
+package remote
+
+import (
+	"fmt"
+
+	"github.com/mitchellh/mapstructure"
+)
+
+type RemoteEncryptionConfig interface {
+	UnmarshalConfig(encryptionConfig map[string]interface{}) error
+	ToMap() (map[string]interface{}, error)
+}
+
+type RemoteEncryptionKeyProvider interface {
+	RemoteEncryptionKeyProviderPBKDF2 | RemoteEncryptionKeyProviderGCPKMS | RemoteEncryptionKeyProviderAWSKMS
+}
+
+type RemoteEncryptionKeyProviderBase struct {
+	KeyProvider string `mapstructure:"key_provider"`
+}
+
+type GenericRemoteEncryptionKeyProvider[T RemoteEncryptionKeyProvider] struct {
+	T T `mapstructure:",squash"`
+}
+
+func (b *GenericRemoteEncryptionKeyProvider[T]) UnmarshalConfig(encryptionConfig map[string]interface{}) error {
+	// Decode the key provider type using the default decoder config
+	if err := mapstructure.Decode(encryptionConfig, &b); err != nil {
+		return fmt.Errorf("failed to decode key provider: %w", err)
+	}
+
+	// Decode the key provider properties using, setting ErrorUnused to true to catch any unused properties
+	decoderConfig := &mapstructure.DecoderConfig{
+		Result:      &b.T,
+		ErrorUnused: true,
+	}
+	decoder, err := mapstructure.NewDecoder(decoderConfig)
+
+	if err != nil {
+		return fmt.Errorf("failed to create decoder: %w", err)
+	}
+
+	if err := decoder.Decode(encryptionConfig); err != nil {
+		return fmt.Errorf("failed to decode key provider properties: %w", err)
+	}
+
+	return nil
+}
+
+func (b *GenericRemoteEncryptionKeyProvider[T]) ToMap() (map[string]interface{}, error) {
+	var result map[string]interface{}
+	err := mapstructure.Decode(b.T, &result)
+
+	if err != nil {
+		return nil, fmt.Errorf("failed to decode struct to map: %w", err)
+	}
+
+	return result, nil
+}
+
+func NewRemoteEncryptionKeyProvider(providerType string) (RemoteEncryptionConfig, error) {
+	switch providerType {
+	case "pbkdf2":
+		return &GenericRemoteEncryptionKeyProvider[RemoteEncryptionKeyProviderPBKDF2]{}, nil
+	case "gcp_kms":
+		return &GenericRemoteEncryptionKeyProvider[RemoteEncryptionKeyProviderGCPKMS]{}, nil
+	case "aws_kms":
+		return &GenericRemoteEncryptionKeyProvider[RemoteEncryptionKeyProviderAWSKMS]{}, nil
+	default:
+		return nil, fmt.Errorf("unknown provider type: %s", providerType)
+	}
+}
+
+type RemoteEncryptionKeyProviderPBKDF2 struct {
+	RemoteEncryptionKeyProviderBase `mapstructure:",squash"`
+	Passphrase                      string `mapstructure:"passphrase"`
+	KeyLength                       int    `mapstructure:"key_length"`
+	Iterations                      int    `mapstructure:"iterations"`
+	SaltLength                      int    `mapstructure:"salt_length"`
+	HashFunction                    string `mapstructure:"hash_function"`
+}
+
+type RemoteEncryptionKeyProviderAWSKMS struct {
+	RemoteEncryptionKeyProviderBase `mapstructure:",squash"`
+	KmsKeyID                        string `mapstructure:"kms_key_id"`
+	KeySpec                         string `mapstructure:"key_spec"`
+	Region                          string `mapstructure:"region"`
+}
+
+type RemoteEncryptionKeyProviderGCPKMS struct {
+	RemoteEncryptionKeyProviderBase `mapstructure:",squash"`
+	KmsEncryptionKey                string `mapstructure:"kms_encryption_key"`
+	KeyLength                       int    `mapstructure:"key_length"`
+}
diff --git a/remote/remote_state.go b/remote/remote_state.go
index b501b3463f..52dce2a93d 100644
--- a/remote/remote_state.go
+++ b/remote/remote_state.go
@@ -24,6 +24,7 @@ type RemoteState struct {
 	DisableDependencyOptimization bool                   `mapstructure:"disable_dependency_optimization" json:"DisableDependencyOptimization"`
 	Generate                      *RemoteStateGenerate   `mapstructure:"generate" json:"Generate"`
 	Config                        map[string]interface{} `mapstructure:"config" json:"Config"`
+	Encryption                    map[string]interface{} `mapstructure:"encryption" json:"Encryption"`
 }
 
 // map to store mutexes for each state bucket action
@@ -40,12 +41,13 @@ var initializedRemoteStateCache = cache.NewCache[bool](initializedRemoteStateCac
 
 func (state *RemoteState) String() string {
 	return fmt.Sprintf(
-		"RemoteState{Backend = %v, DisableInit = %v, DisableDependencyOptimization = %v, Generate = %v, Config = %v}",
+		"RemoteState{Backend = %v, DisableInit = %v, DisableDependencyOptimization = %v, Generate = %v, Config = %v, Encryption = %v}",
 		state.Backend,
 		state.DisableInit,
 		state.DisableDependencyOptimization,
 		state.Generate,
 		state.Config,
+		state.Encryption,
 	)
 }
 
@@ -231,6 +233,36 @@ func (state *RemoteState) GenerateTerraformCode(terragruntOptions *options.Terra
 	// Make sure to strip out terragrunt specific configurations from the config.
 	config := state.Config
 
+	// Initialize the encryption config based on the key provider
+	var encryption map[string]interface{}
+
+	switch {
+	case state.Encryption == nil:
+		terragruntOptions.Logger.Debugf("No encryption block in remote_state config")
+	case len(state.Encryption) == 0:
+		terragruntOptions.Logger.Debugf("Empty encryption block in remote_state config")
+	default:
+		keyProvider, ok := state.Encryption["key_provider"].(string)
+		if !ok {
+			return errors.New("key_provider not found in encryption config")
+		}
+
+		encryptionProvider, err := NewRemoteEncryptionKeyProvider(keyProvider)
+		if err != nil {
+			return fmt.Errorf("error creating provider: %w", err)
+		}
+
+		err = encryptionProvider.UnmarshalConfig(state.Encryption)
+		if err != nil {
+			return err
+		}
+
+		encryption, err = encryptionProvider.ToMap()
+		if err != nil {
+			return fmt.Errorf("error decoding struct to map: %w", err)
+		}
+	}
+
 	initializer, hasInitializer := remoteStateInitializers[state.Backend]
 	if hasInitializer {
 		config = initializer.GetTerraformInitArgs(config)
@@ -242,7 +274,7 @@ func (state *RemoteState) GenerateTerraformCode(terragruntOptions *options.Terra
 		return err
 	}
 
-	configBytes, err := codegen.RemoteStateConfigToTerraformCode(state.Backend, config)
+	configBytes, err := codegen.RemoteStateConfigToTerraformCode(state.Backend, config, encryption)
 	if err != nil {
 		return err
 	}


Test patch:
diff --git a/codegen/generate_test.go b/codegen/generate_test.go
index 95357d95e9..aa0219534a 100644
--- a/codegen/generate_test.go
+++ b/codegen/generate_test.go
@@ -21,19 +21,55 @@ func TestRemoteStateConfigToTerraformCode(t *testing.T) {
     b = 2
     c = 3
   }
+  encryption {
+    key_provider "test" "default" {
+      a = 1
+      b = 2
+      c = 3
+    }
+    method "aes_gcm" "default" {
+      keys = key_provider.test.default
+    }
+    state {
+      method = method.aes_gcm.default
+    }
+    plan {
+      method = method.aes_gcm.default
+    }
+  }
+}
+`)
+	expectedEmptyConfig := []byte(`terraform {
+  backend "empty" {
+  }
+  encryption {
+    key_provider "test" "default" {
+    }
+    method "aes_gcm" "default" {
+      keys = key_provider.test.default
+    }
+    state {
+      method = method.aes_gcm.default
+    }
+    plan {
+      method = method.aes_gcm.default
+    }
+  }
 }
 `)
-	expectedEmpty := []byte(`terraform {
+	expectedEmptyEncryption := []byte(`terraform {
   backend "empty" {
   }
 }
 `)
 
 	tc := []struct {
-		name     string
-		backend  string
-		config   map[string]interface{}
-		expected []byte
+		name       string
+		backend    string
+		config     map[string]interface{}
+		encryption map[string]interface{}
+		expected   []byte
+		expectErr  bool
 	}{
 		{
 			"remote-state-config-unsorted-keys",
@@ -43,13 +79,42 @@ func TestRemoteStateConfigToTerraformCode(t *testing.T) {
 				"a": 1,
 				"c": 3,
 			},
+			map[string]interface{}{
+				"key_provider": "test",
+				"b":            2,
+				"a":            1,
+				"c":            3,
+			},
 			expectedOrdered,
+			false,
 		},
 		{
 			"remote-state-config-empty",
 			"empty",
 			map[string]interface{}{},
-			expectedEmpty,
+			map[string]interface{}{
+				"key_provider": "test",
+			},
+			expectedEmptyConfig,
+			false,
+		},
+		{
+			"remote-state-encryption-empty",
+			"empty",
+			map[string]interface{}{},
+			map[string]interface{}{},
+			expectedEmptyEncryption,
+			false,
+		},
+		{
+			"remote-state-encryption-missing-key-provider",
+			"empty",
+			map[string]interface{}{},
+			map[string]interface{}{
+				"a": 1,
+			},
+			[]byte(""),
+			true,
 		},
 	}
 
@@ -59,16 +124,20 @@ func TestRemoteStateConfigToTerraformCode(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
 
-			output, err := codegen.RemoteStateConfigToTerraformCode(tt.backend, tt.config)
+			output, err := codegen.RemoteStateConfigToTerraformCode(tt.backend, tt.config, tt.encryption)
 			// validates the first output.
-			assert.True(t, bytes.Contains(output, []byte(tt.backend)))
-			assert.Equal(t, tt.expected, output)
-			require.NoError(t, err)
+			if tt.expectErr {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				assert.True(t, bytes.Contains(output, []byte(tt.backend)))
+				assert.Equal(t, tt.expected, output)
+			}
 
 			// runs the function a few of times again. All the outputs must be
 			// equal to the first output.
 			for i := 0; i < 20; i++ {
-				actual, _ := codegen.RemoteStateConfigToTerraformCode(tt.backend, tt.config)
+				actual, _ := codegen.RemoteStateConfigToTerraformCode(tt.backend, tt.config, tt.encryption)
 				assert.Equal(t, output, actual)
 			}
 		})
diff --git a/config/config_as_cty_test.go b/config/config_as_cty_test.go
index 78b64b6118..e8f4151033 100644
--- a/config/config_as_cty_test.go
+++ b/config/config_as_cty_test.go
@@ -178,6 +178,9 @@ func TestRemoteStateAsCtyDrift(t *testing.T) {
 		Config: map[string]interface{}{
 			"bar": "baz",
 		},
+		Encryption: map[string]interface{}{
+			"bar": "baz",
+		},
 	}
 
 	ctyVal, err := config.RemoteStateAsCty(&testConfig)
@@ -301,6 +304,8 @@ func remoteStateStructFieldToMapKey(t *testing.T, fieldName string) (string, boo
 		return "generate", true
 	case "Config":
 		return "config", true
+	case "Encryption":
+		return "encryption", true
 	default:
 		t.Fatalf("Unknown struct property: %s", fieldName)
 		// This should not execute
diff --git a/config/config_test.go b/config/config_test.go
index d668192cc4..a92dda4248 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -30,8 +30,9 @@ func TestParseTerragruntConfigRemoteStateMinimalConfig(t *testing.T) {
 
 	cfg := `
 remote_state {
-  backend = "s3"
-  config  = {}
+  backend 	  = "s3"
+  config  	  = {}
+  encryption  = {}
 }
 `
 
@@ -46,6 +47,7 @@ remote_state {
 	if assert.NotNil(t, terragruntConfig.RemoteState) {
 		assert.Equal(t, "s3", terragruntConfig.RemoteState.Backend)
 		assert.Empty(t, terragruntConfig.RemoteState.Config)
+		assert.Empty(t, terragruntConfig.RemoteState.Encryption)
 	}
 }
 
@@ -124,6 +126,10 @@ remote_state {
   		key = "terraform.tfstate"
   		region = "us-east-1"
 	}
+	encryption = {
+		key_provider = "pbkdf2"
+		passphrase = "correct-horse-battery-staple"
+	}
 }
 `
 
@@ -144,6 +150,8 @@ remote_state {
 		assert.Equal(t, "my-bucket", terragruntConfig.RemoteState.Config["bucket"])
 		assert.Equal(t, "terraform.tfstate", terragruntConfig.RemoteState.Config["key"])
 		assert.Equal(t, "us-east-1", terragruntConfig.RemoteState.Config["region"])
+		assert.Equal(t, "pbkdf2", terragruntConfig.RemoteState.Encryption["key_provider"])
+		assert.Equal(t, "correct-horse-battery-staple", terragruntConfig.RemoteState.Encryption["passphrase"])
 	}
 }
 
@@ -159,6 +167,10 @@ func TestParseTerragruntJsonConfigRemoteStateFullConfig(t *testing.T) {
 			"bucket": "my-bucket",
 			"key": "terraform.tfstate",
 			"region":"us-east-1"
+		},
+		"encryption":{
+			"key_provider": "pbkdf2",
+			"passphrase": "correct-horse-battery-staple"
 		}
 	}
 }
@@ -180,6 +192,8 @@ func TestParseTerragruntJsonConfigRemoteStateFullConfig(t *testing.T) {
 		assert.Equal(t, "my-bucket", terragruntConfig.RemoteState.Config["bucket"])
 		assert.Equal(t, "terraform.tfstate", terragruntConfig.RemoteState.Config["key"])
 		assert.Equal(t, "us-east-1", terragruntConfig.RemoteState.Config["region"])
+		assert.Equal(t, "pbkdf2", terragruntConfig.RemoteState.Encryption["key_provider"])
+		assert.Equal(t, "correct-horse-battery-staple", terragruntConfig.RemoteState.Encryption["passphrase"])
 	}
 }
 
diff --git a/remote/remote_encryption_test.go b/remote/remote_encryption_test.go
new file mode 100644
index 0000000000..774511a651
--- /dev/null
+++ b/remote/remote_encryption_test.go
@@ -0,0 +1,238 @@
+package remote_test
+
+import (
+	"testing"
+
+	"github.com/gruntwork-io/terragrunt/remote"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestUnmarshalConfig(t *testing.T) {
+	t.Parallel()
+
+	tc := []struct {
+		name             string
+		providerType     string
+		encryptionConfig map[string]interface{}
+		expectedError    bool
+	}{
+		{
+			name:         "PBKDF2 full config",
+			providerType: "pbkdf2",
+			encryptionConfig: map[string]interface{}{
+				"key_provider":  "pbkdf2",
+				"passphrase":    "passphrase",
+				"key_length":    32,
+				"iterations":    10000,
+				"salt_length":   16,
+				"hash_function": "sha256",
+			},
+			expectedError: false,
+		},
+		{
+			name:         "PBKDF2 invalid property",
+			providerType: "pbkdf2",
+			encryptionConfig: map[string]interface{}{
+				"key_provider": "pbkdf2",
+				"password":     "password123", // Invalid property
+			},
+			expectedError: true,
+		},
+		{
+			name:         "PBKDF2 invalid config",
+			providerType: "pbkdf2",
+			encryptionConfig: map[string]interface{}{
+				"key_provider": "pbkdf2",
+				"passphrase":   123, // Invalid type
+			},
+			expectedError: true,
+		},
+		{
+			name:         "AWSKMS full config",
+			providerType: "aws_kms",
+			encryptionConfig: map[string]interface{}{
+				"key_provider": "aws_kms",
+				"kms_key_id":   "123456789",
+				"key_spec":     "AES_256",
+			},
+			expectedError: false,
+		},
+		{
+			name:         "AWSKMS invalid property",
+			providerType: "aws_kms",
+			encryptionConfig: map[string]interface{}{
+				"key_provider": "aws_kms",
+				"password":     "password123", // Invalid property
+			},
+			expectedError: true,
+		},
+		{
+			name:         "AWSKMS invalid config",
+			providerType: "aws_kms",
+			encryptionConfig: map[string]interface{}{
+				"key_provider": "aws_kms",
+				"kms_key_id":   123456789, // Invalid type
+				"key_spec":     "AES_256",
+			},
+			expectedError: true,
+		},
+		{
+			name:         "GCPKMS full config",
+			providerType: "gcp_kms",
+			encryptionConfig: map[string]interface{}{
+				"key_provider":       "gcp_kms",
+				"kms_encryption_key": "projects/123456789/locations/global/keyRings/my-key-ring/cryptoKeys/my-key",
+				"key_length":         32,
+			},
+			expectedError: false,
+		},
+		{
+			name:         "GCPKMS invalid property",
+			providerType: "gcp_kms",
+			encryptionConfig: map[string]interface{}{
+				"key_provider": "gcp_kms",
+				"password":     "password123", // Invalid property
+			},
+			expectedError: true,
+		},
+		{
+			name:         "GCPKMS invalid config",
+			providerType: "gcp_kms",
+			encryptionConfig: map[string]interface{}{
+				"key_provider":       "gcp_kms",
+				"kms_encryption_key": 123456789, // Invalid type
+				"key_length":         32,
+			},
+			expectedError: true,
+		},
+	}
+
+	for _, tt := range tc {
+		tt := tt
+
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			provider, err := remote.NewRemoteEncryptionKeyProvider(tt.providerType)
+			if err != nil {
+				t.Fatalf("failed to create provider: %v", err)
+			}
+
+			err = provider.UnmarshalConfig(tt.encryptionConfig)
+			if tt.expectedError {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+func TestToMap(t *testing.T) {
+	t.Parallel()
+
+	tc := []struct {
+		name             string
+		providerType     string
+		encryptionConfig map[string]interface{}
+		expectedMap      map[string]interface{}
+		expectedError    bool
+	}{
+		{
+			name:         "PBKDF2 full config",
+			providerType: "pbkdf2",
+			encryptionConfig: map[string]interface{}{
+				"key_provider":  "pbkdf2",
+				"passphrase":    "passphrase",
+				"key_length":    32,
+				"iterations":    10000,
+				"salt_length":   16,
+				"hash_function": "sha256",
+			},
+			expectedMap: map[string]interface{}{
+				"key_provider":  "pbkdf2",
+				"passphrase":    "passphrase",
+				"key_length":    32,
+				"iterations":    10000,
+				"salt_length":   16,
+				"hash_function": "sha256",
+			},
+			expectedError: false,
+		},
+		{
+			name:         "PBKDF2 partial config",
+			providerType: "pbkdf2",
+			encryptionConfig: map[string]interface{}{
+				"key_provider": "pbkdf2",
+				"passphrase":   "passphrase",
+			},
+			expectedMap: map[string]interface{}{
+				"key_provider":  "pbkdf2",
+				"passphrase":    "passphrase",
+				"key_length":    0,
+				"iterations":    0,
+				"salt_length":   0,
+				"hash_function": "",
+			},
+			expectedError: false,
+		},
+		{
+			name:         "AWSKMS full config",
+			providerType: "aws_kms",
+			encryptionConfig: map[string]interface{}{
+				"key_provider": "aws_kms",
+				"region":       "us-west-1",
+				"kms_key_id":   "123456789",
+				"key_spec":     "AES_256",
+			},
+			expectedMap: map[string]interface{}{
+				"key_provider": "aws_kms",
+				"region":       "us-west-1",
+				"kms_key_id":   "123456789",
+				"key_spec":     "AES_256",
+			},
+			expectedError: false,
+		},
+		{
+			name:         "GCPKMS full config",
+			providerType: "gcp_kms",
+			encryptionConfig: map[string]interface{}{
+				"key_provider":       "gcp_kms",
+				"kms_encryption_key": "projects/123456789/locations/global/keyRings/my-key-ring/cryptoKeys/my-key",
+				"key_length":         32,
+			},
+			expectedMap: map[string]interface{}{
+				"key_provider":       "gcp_kms",
+				"kms_encryption_key": "projects/123456789/locations/global/keyRings/my-key-ring/cryptoKeys/my-key",
+				"key_length":         32,
+			},
+			expectedError: false,
+		},
+	}
+
+	for _, tt := range tc {
+		tt := tt
+
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			provider, err := remote.NewRemoteEncryptionKeyProvider(tt.providerType)
+			if err != nil {
+				t.Fatalf("failed to create provider: %v", err)
+			}
+
+			err = provider.UnmarshalConfig(tt.encryptionConfig)
+			if err != nil {
+				t.Fatalf("failed to unmarshal config: %v", err)
+			}
+
+			result, err := provider.ToMap()
+			if tt.expectedError {
+				assert.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				assert.Equal(t, tt.expectedMap, result)
+			}
+		})
+	}
+}
diff --git a/test/fixtures/read-config/full/source.hcl b/test/fixtures/read-config/full/source.hcl
index 183ecee33c..74e1ef114c 100644
--- a/test/fixtures/read-config/full/source.hcl
+++ b/test/fixtures/read-config/full/source.hcl
@@ -21,6 +21,9 @@ remote_state {
   config = {
     path = "foo.tfstate"
   }
+  encryption = {
+    key_provider = "foo"
+  }
 }
 
 terraform {
diff --git a/test/fixtures/render-json-with-encryption/common_vars.hcl b/test/fixtures/render-json-with-encryption/common_vars.hcl
new file mode 100644
index 0000000000..cdcc9ab6a3
--- /dev/null
+++ b/test/fixtures/render-json-with-encryption/common_vars.hcl
@@ -0,0 +1,8 @@
+dependency "dep" {
+  config_path = "../dep"
+}
+
+inputs = {
+  env  = "qa"
+  name = dependency.dep.outputs.name
+}
diff --git a/test/fixtures/render-json-with-encryption/dep/main.tf b/test/fixtures/render-json-with-encryption/dep/main.tf
new file mode 100644
index 0000000000..1f7b349e2e
--- /dev/null
+++ b/test/fixtures/render-json-with-encryption/dep/main.tf
@@ -0,0 +1,3 @@
+output "name" {
+  value = "dep"
+}
diff --git a/test/fixtures/render-json-with-encryption/dep/terragrunt.hcl b/test/fixtures/render-json-with-encryption/dep/terragrunt.hcl
new file mode 100644
index 0000000000..bb7b160deb
--- /dev/null
+++ b/test/fixtures/render-json-with-encryption/dep/terragrunt.hcl
@@ -0,0 +1,1 @@
+# Intentionally empty
diff --git a/test/fixtures/render-json-with-encryption/main/module/main.tf b/test/fixtures/render-json-with-encryption/main/module/main.tf
new file mode 100644
index 0000000000..21f83b8ebd
--- /dev/null
+++ b/test/fixtures/render-json-with-encryption/main/module/main.tf
@@ -0,0 +1,17 @@
+variable "aws_region" {}
+variable "env" {}
+variable "name" {}
+variable "type" {}
+
+output "aws_region" {
+  value = var.aws_region
+}
+output "env" {
+  value = var.env
+}
+output "name" {
+  value = var.name
+}
+output "type" {
+  value = var.type
+}
diff --git a/test/fixtures/render-json-with-encryption/main/terragrunt.hcl b/test/fixtures/render-json-with-encryption/main/terragrunt.hcl
new file mode 100644
index 0000000000..a319e22711
--- /dev/null
+++ b/test/fixtures/render-json-with-encryption/main/terragrunt.hcl
@@ -0,0 +1,15 @@
+terraform {
+  source = "./module"
+}
+
+include "root" {
+  path = find_in_parent_folders("root.hcl")
+}
+
+include "envcommon" {
+  path = find_in_parent_folders("common_vars.hcl")
+}
+
+inputs = {
+  type = "main"
+}
diff --git a/test/fixtures/render-json-with-encryption/root.hcl b/test/fixtures/render-json-with-encryption/root.hcl
new file mode 100644
index 0000000000..c605f1ff2b
--- /dev/null
+++ b/test/fixtures/render-json-with-encryption/root.hcl
@@ -0,0 +1,28 @@
+remote_state {
+  backend = "local"
+  generate = {
+    path = "backend.tf"
+    if_exists = "overwrite_terragrunt"
+  }
+  config = {
+    path = "foo.tfstate"
+  }
+  encryption = {
+    key_provider = "pbkdf2"
+    passphrase   = "correct-horse-battery-staple"
+  }
+}
+
+generate "provider" {
+  path = "provider.tf"
+  if_exists = "overwrite_terragrunt"
+  contents = <<EOF
+provider "aws" {
+  region = "us-east-1"
+}
+EOF
+}
+
+inputs = {
+  aws_region = "us-east-1"
+}
diff --git a/test/fixtures/tofu-state-encryption/aws-kms/terragrunt.hcl b/test/fixtures/tofu-state-encryption/aws-kms/terragrunt.hcl
new file mode 100644
index 0000000000..abe6f5871d
--- /dev/null
+++ b/test/fixtures/tofu-state-encryption/aws-kms/terragrunt.hcl
@@ -0,0 +1,20 @@
+# Test AWS KMS encryption with local state
+remote_state {
+  backend = "local"
+
+  generate = {
+    path      = "backend.tf"
+    if_exists = "overwrite_terragrunt"
+  }
+
+  config = {
+    path = "${get_terragrunt_dir()}/${path_relative_to_include()}/terraform.tfstate"
+  }
+
+  encryption = {
+    key_provider = "aws_kms"
+    region       = "__FILL_IN_AWS_REGION__"
+    kms_key_id   = "__FILL_IN_KMS_KEY_ID__"
+    key_spec     = "AES_256"
+  }
+}
diff --git a/test/fixtures/tofu-state-encryption/gcp-kms/terragrunt.hcl b/test/fixtures/tofu-state-encryption/gcp-kms/terragrunt.hcl
new file mode 100644
index 0000000000..862df680e8
--- /dev/null
+++ b/test/fixtures/tofu-state-encryption/gcp-kms/terragrunt.hcl
@@ -0,0 +1,19 @@
+# Test GCP KMS encryption with local state
+remote_state {
+  backend = "local"
+
+  generate = {
+    path      = "backend.tf"
+    if_exists = "overwrite_terragrunt"
+  }
+
+  config = {
+    path = "${get_terragrunt_dir()}/${path_relative_to_include()}/terraform.tfstate"
+  }
+
+  encryption = {
+    key_provider       = "gcp_kms"
+    kms_encryption_key = "__FILL_IN_KMS_KEY_ID__"
+    key_length         = 1024
+  }
+}
diff --git a/test/fixtures/tofu-state-encryption/pbkdf2/terragrunt.hcl b/test/fixtures/tofu-state-encryption/pbkdf2/terragrunt.hcl
new file mode 100644
index 0000000000..9c52dd1cba
--- /dev/null
+++ b/test/fixtures/tofu-state-encryption/pbkdf2/terragrunt.hcl
@@ -0,0 +1,18 @@
+# Test PBKDF2 encryption with local state
+remote_state {
+  backend = "local"
+
+  generate = {
+    path      = "backend.tf"
+    if_exists = "overwrite_terragrunt"
+  }
+
+  config = {
+    path = "${get_terragrunt_dir()}/${path_relative_to_include()}/terraform.tfstate"
+  }
+
+  encryption = {
+    key_provider = "pbkdf2"
+    passphrase = "randompassphrase123456"
+  }
+}
diff --git a/test/integration_debug_test.go b/test/integration_debug_test.go
index ff01f8352a..e568ca5efa 100644
--- a/test/integration_debug_test.go
+++ b/test/integration_debug_test.go
@@ -220,6 +220,7 @@ func TestRenderJSONConfig(t *testing.T) {
 					"path": "foo.tfstate",
 				},
 				"disable_init":                    false,
+				"encryption":                      nil,
 				"disable_dependency_optimization": false,
 			},
 			remoteState.(map[string]interface{}),
diff --git a/test/integration_include_test.go b/test/integration_include_test.go
index f6d70fb676..8456c6034b 100644
--- a/test/integration_include_test.go
+++ b/test/integration_include_test.go
@@ -284,6 +284,7 @@ func validateIncludeRemoteStateReflection(t *testing.T, s3BucketName string, key
 				"key":     keyPath + "/terraform.tfstate",
 				"region":  "us-west-2",
 			},
+			"encryption": nil,
 		},
 		remoteStateOut,
 	)
diff --git a/test/integration_json_test.go b/test/integration_json_test.go
index 276215b7cb..3c1e7fcc3d 100644
--- a/test/integration_json_test.go
+++ b/test/integration_json_test.go
@@ -315,6 +315,7 @@ func TestRenderJsonMetadataIncludes(t *testing.T) {
 				"key":    "path/to/my/key",
 				"region": "us-east-1",
 			},
+			"encryption": nil,
 		},
 	}
 
@@ -460,6 +461,7 @@ func TestRenderJsonMetadataTerraform(t *testing.T) {
 				"key":    "path/to/my/key",
 				"region": "us-east-1",
 			},
+			"encryption":                      nil,
 			"disable_dependency_optimization": false,
 			"disable_init":                    false,
 			"generate":                        nil,
diff --git a/test/integration_test.go b/test/integration_test.go
index 13d5ae710d..d702092887 100644
--- a/test/integration_test.go
+++ b/test/integration_test.go
@@ -2386,6 +2386,7 @@ func TestReadTerragruntConfigFull(t *testing.T) {
 			"disable_dependency_optimization": false,
 			"generate":                        map[string]interface{}{"path": "backend.tf", "if_exists": "overwrite_terragrunt"},
 			"config":                          map[string]interface{}{"path": "foo.tfstate"},
+			"encryption":                      map[string]interface{}{"key_provider": "foo"},
 		},
 		remoteStateOut,
 	)
diff --git a/test/integration_tofu_state_encryption_test.go b/test/integration_tofu_state_encryption_test.go
new file mode 100644
index 0000000000..a49cd98cbc
--- /dev/null
+++ b/test/integration_tofu_state_encryption_test.go
@@ -0,0 +1,219 @@
+//go:build tofu
+
+package test_test
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/gruntwork-io/terragrunt/test/helpers"
+	"github.com/gruntwork-io/terragrunt/util"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const (
+	testFixtureTofuStateEncryptionPBKDF2 = "fixtures/tofu-state-encryption/pbkdf2"
+	testFixtureTofuStateEncryptionGCPKMS = "fixtures/tofu-state-encryption/gcp-kms"
+	testFixtureTofuStateEncryptionAWSKMS = "fixtures/tofu-state-encryption/aws-kms"
+	testFixtureRenderJSONWithEncryption  = "fixtures/render-json-with-encryption"
+	gcpKMSKeyID                          = "projects/terragrunt-test/locations/global/keyRings/terragrunt-test/cryptoKeys/terragrunt-test-key"
+	awsKMSKeyID                          = "bd372994-d969-464a-a261-6cc850c58a92"
+	stateFile                            = "terraform.tfstate"
+	awsKMSKeyRegion                      = "us-east-1"
+)
+
+var testFixtureRenderJSONWithEncryptionMainModulePath = filepath.Join(testFixtureRenderJSONWithEncryption, "main")
+
+func TestTofuStateEncryptionPBKDF2(t *testing.T) {
+	t.Parallel()
+
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureTofuStateEncryptionPBKDF2)
+	workDir := util.JoinPath(tmpEnvPath, testFixtureTofuStateEncryptionPBKDF2)
+
+	helpers.RunTerragrunt(t, "terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir "+workDir)
+	assert.True(t, helpers.FileIsInFolder(t, stateFile, workDir))
+	validateStateIsEncrypted(t, stateFile, workDir)
+}
+
+func TestTofuStateEncryptionGCPKMS(t *testing.T) {
+	t.Skip("Skipping test as the GCP KMS key is not available. You have to setup your own GCP KMS key to run this test.")
+	t.Parallel()
+
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureTofuStateEncryptionGCPKMS)
+	workDir := util.JoinPath(tmpEnvPath, testFixtureTofuStateEncryptionGCPKMS)
+	configPath := util.JoinPath(workDir, "terragrunt.hcl")
+
+	helpers.CopyAndFillMapPlaceholders(t, configPath, configPath, map[string]string{
+		"__FILL_IN_KMS_KEY_ID__": gcpKMSKeyID,
+	})
+
+	helpers.RunTerragrunt(t, "terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir "+workDir)
+	assert.True(t, helpers.FileIsInFolder(t, stateFile, workDir))
+	validateStateIsEncrypted(t, stateFile, workDir)
+}
+
+func TestTofuStateEncryptionAWSKMS(t *testing.T) {
+	t.Parallel()
+
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureTofuStateEncryptionAWSKMS)
+	workDir := util.JoinPath(tmpEnvPath, testFixtureTofuStateEncryptionAWSKMS)
+	configPath := util.JoinPath(workDir, "terragrunt.hcl")
+
+	helpers.CopyAndFillMapPlaceholders(t, configPath, configPath, map[string]string{
+		"__FILL_IN_KMS_KEY_ID__": awsKMSKeyID,
+		"__FILL_IN_AWS_REGION__": awsKMSKeyRegion,
+	})
+
+	helpers.RunTerragrunt(t, "terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir "+workDir)
+	assert.True(t, helpers.FileIsInFolder(t, stateFile, workDir))
+	validateStateIsEncrypted(t, stateFile, workDir)
+}
+
+func TestTofuRenderJSONConfigWithEncryption(t *testing.T) {
+	t.Parallel()
+
+	tmpDir, err := os.MkdirTemp("", "terragrunt-render-json-*")
+	require.NoError(t, err)
+	jsonOut := filepath.Join(tmpDir, "terragrunt_rendered.json")
+	defer os.RemoveAll(tmpDir)
+
+	helpers.CleanupTerraformFolder(t, fixtureRenderJSONMainModulePath)
+	helpers.CleanupTerraformFolder(t, fixtureRenderJSONDepModulePath)
+
+	helpers.RunTerragrunt(t, "terragrunt run-all apply -auto-approve --terragrunt-non-interactive --terragrunt-log-level trace --terragrunt-working-dir "+testFixtureRenderJSONWithEncryption)
+	helpers.RunTerragrunt(t, fmt.Sprintf("terragrunt render-json --terragrunt-non-interactive --terragrunt-log-level trace --terragrunt-working-dir %s --terragrunt-json-out %s", testFixtureRenderJSONWithEncryptionMainModulePath, jsonOut))
+
+	jsonBytes, err := os.ReadFile(jsonOut)
+	require.NoError(t, err)
+
+	var rendered map[string]interface{}
+	require.NoError(t, json.Unmarshal(jsonBytes, &rendered))
+
+	// Make sure all terraform block is visible
+	terraformBlock, hasTerraform := rendered["terraform"]
+	if assert.True(t, hasTerraform) {
+		source, hasSource := terraformBlock.(map[string]interface{})["source"]
+		assert.True(t, hasSource)
+		assert.Equal(t, "./module", source)
+	}
+
+	// Make sure included remoteState is rendered out
+	remoteState, hasRemoteState := rendered["remote_state"]
+	if assert.True(t, hasRemoteState) {
+		assert.Equal(
+			t,
+			map[string]interface{}{
+				"backend": "local",
+				"generate": map[string]interface{}{
+					"path":      "backend.tf",
+					"if_exists": "overwrite_terragrunt",
+				},
+				"config": map[string]interface{}{
+					"path": "foo.tfstate",
+				},
+				"encryption": map[string]interface{}{
+					"key_provider": "pbkdf2",
+					"passphrase":   "correct-horse-battery-staple",
+				},
+				"disable_init":                    false,
+				"disable_dependency_optimization": false,
+			},
+			remoteState.(map[string]interface{}),
+		)
+	}
+
+	// Make sure dependency blocks are rendered out
+	dependencyBlocks, hasDependency := rendered["dependency"]
+	if assert.True(t, hasDependency) {
+		assert.Equal(
+			t,
+			map[string]interface{}{
+				"dep": map[string]interface{}{
+					"name":         "dep",
+					"config_path":  "../dep",
+					"outputs":      nil,
+					"inputs":       nil,
+					"mock_outputs": nil,
+					"enabled":      nil,
+					"mock_outputs_allowed_terraform_commands": nil,
+					"mock_outputs_merge_strategy_with_state":  nil,
+					"mock_outputs_merge_with_state":           nil,
+					"skip":                                    nil,
+				},
+			},
+			dependencyBlocks.(map[string]interface{}),
+		)
+	}
+
+	// Make sure included generate block is rendered out
+	generateBlocks, hasGenerate := rendered["generate"]
+	if assert.True(t, hasGenerate) {
+		assert.Equal(
+			t,
+			map[string]interface{}{
+				"provider": map[string]interface{}{
+					"path":              "provider.tf",
+					"comment_prefix":    "# ",
+					"disable_signature": false,
+					"disable":           false,
+					"if_exists":         "overwrite_terragrunt",
+					"if_disabled":       "skip",
+					"contents": `provider "aws" {
+  region = "us-east-1"
+}
+`,
+				},
+			},
+			generateBlocks.(map[string]interface{}),
+		)
+	}
+
+	// Make sure all inputs are merged together
+	inputsBlock, hasInputs := rendered["inputs"]
+	if assert.True(t, hasInputs) {
+		assert.Equal(
+			t,
+			map[string]interface{}{
+				"env":        "qa",
+				"name":       "dep",
+				"type":       "main",
+				"aws_region": "us-east-1",
+			},
+			inputsBlock.(map[string]interface{}),
+		)
+	}
+}
+
+// Check the statefile contains an encrypted_data key
+// and that the encrypted_data is base64 encoded
+func validateStateIsEncrypted(t *testing.T, fileName string, path string) {
+	t.Helper()
+
+	filePath := filepath.Join(path, fileName)
+	file, err := os.Open(filePath)
+	require.NoError(t, err)
+	defer file.Close()
+
+	byteValue, err := io.ReadAll(file)
+	require.NoError(t, err)
+
+	var result map[string]interface{}
+	err = json.Unmarshal(byteValue, &result)
+	require.NoError(t, err, "Error unmarshalling the state file '%s'", fileName)
+
+	encryptedData, exists := result["encrypted_data"]
+	assert.True(t, exists, "The key 'encrypted_data' should exist in the state '%s'", fileName)
+
+	// Check if the encrypted_data is base64 encoded (common for AES-256 encrypted data)
+	encryptedDataStr, ok := encryptedData.(string)
+	assert.True(t, ok, "The value of 'encrypted_data' should be a string")
+
+	_, err = base64.StdEncoding.DecodeString(encryptedDataStr)
+	assert.NoError(t, err, "The value of 'encrypted_data' should be base64 encoded, indicating AES-256 encryption")
+}