DEV Community

Shrijith Venkatramana
Shrijith Venkatramana

Posted on

Runtime Memory Encryption in Golang Apps

Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a first of its kind tool for helping you automatically index API endpoints across all your repositories. LiveAPI helps you discover, understand and use APIs in large tech infrastructures with ease.

Memory encryption in Go isn't a topic you hear about every day, but it's a powerful tool for securing sensitive data in your applications. When you’re dealing with things like API keys, user credentials, or financial data, you want to make sure they’re not sitting in memory as plain text, just waiting for a memory scrape or a rogue process to snatch them. Runtime memory encryption in Go lets you protect this data by encrypting it while your program runs, making it unreadable to unauthorized access.

In this post, we’ll dig into how you can implement runtime memory encryption in Go, why it matters, and what trade-offs you’re dealing with. We’ll cover practical examples, code you can actually run, and some real-world considerations. Let’s break it down.

Why Encrypt Memory at Runtime?

Sensitive data in memory—like passwords or encryption keys—can be exposed if an attacker gets access to your system’s memory. Tools like memory dumpers or even debuggers can read plain-text data from a running process. Runtime memory encryption ensures that sensitive data is stored encrypted in memory and only decrypted when needed, reducing the window of vulnerability.

This is especially critical for:

  • High-security applications like banking or healthcare systems.
  • Servers handling API keys or user tokens.
  • Compliance requirements (think GDPR, HIPAA, or PCI-DSS).

The Go runtime doesn’t natively encrypt memory, but you can leverage libraries and techniques to achieve this. Let’s explore how.

Understanding Go’s Memory Model

Before we jump into encryption, you need to know how Go handles memory. Go’s memory management is built around its garbage collector (GC), which allocates and deallocates memory for your variables. Sensitive data, like strings or slices, can linger in memory longer than you expect because the GC decides when to clean things up.

Here’s what you need to know:

  • Strings are immutable: Once created, they stick around until the GC collects them.
  • Slices can be copied: Data might exist in multiple memory locations.
  • Memory isn’t zeroed: Freed memory can still contain sensitive data until overwritten.

This makes runtime encryption tricky but necessary. You’ll need to manage how data is stored and ensure it’s encrypted as soon as possible.

Setting Up a Basic Encryption Layer

To encrypt data in memory, we’ll use Go’s crypto/aes package for AES encryption. AES is fast, secure, and widely supported. The idea is to encrypt sensitive data before storing it in variables and decrypt it only when you need to use it.

Here’s a simple example that encrypts a string and stores it in memory:

package main

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "fmt"
    "io"
)

// EncryptString encrypts a string using AES and a key.
func EncryptString(plaintext string, key []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }

    // Pad the plaintext to match AES block size
    plaintextBytes := []byte(plaintext)
    padding := aes.BlockSize - len(plaintextBytes)%aes.BlockSize
    for i := 0; i < padding; i++ {
        plaintextBytes = append(plaintextBytes, byte(padding))
    }

    // Generate IV
    iv := make([]byte, aes.BlockSize)
    if _, err := io.ReadFull(rand.Reader, iv); err != nil {
        return nil, err
    }

    // Encrypt
    ciphertext := make([]byte, len(plaintextBytes))
    mode := cipher.NewCBCEncrypter(block, iv)
    mode.CryptBlocks(ciphertext, plaintextBytes)

    // Prepend IV to ciphertext
    return append(iv, ciphertext...), nil
}

// DecryptString decrypts a string using AES and a key.
func DecryptString(ciphertext, key []byte) (string, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return "", err
    }

    // Extract IV
    iv := ciphertext[:aes.BlockSize]
    ciphertext = ciphertext[aes.BlockSize:]

    // Decrypt
    plaintext := make([]byte, len(ciphertext))
    mode := cipher.NewCBCDecrypter(block, iv)
    mode.CryptBlocks(plaintext, ciphertext)

    // Remove padding
    padding := int(plaintext[len(plaintext)-1])
    plaintext = plaintext[:len(plaintext)-padding]

    return string(plaintext), nil
}

func main() {
    key := make([]byte, 32) // 256-bit key
    _, err := io.ReadFull(rand.Reader, key)
    if err != nil {
        panic(err)
    }

    // Sensitive data
    secret := "my-super-secret-api-key"
    encrypted, err := EncryptString(secret, key)
    if err != nil {
        panic(err)
    }

    // Data in memory is encrypted
    fmt.Printf("Encrypted (hex): %x\n", encrypted)

    // Decrypt when needed
    decrypted, err := DecryptString(encrypted, key)
    if err != nil {
        panic(err)
    }
    fmt.Printf("Decrypted: %s\n", decrypted)

    // Output:
    // Encrypted (hex): <random hex string>
    // Decrypted: my-super-secret-api-key
}
Enter fullscreen mode Exit fullscreen mode

This code encrypts a string using AES-CBC, stores the encrypted bytes in memory, and decrypts it when needed. The plaintext never sits in memory unencrypted, except briefly during encryption/decryption.

AES documentation

Managing Encryption Keys Securely

The encryption key is just as sensitive as the data itself. If an attacker gets the key, your encryption is useless. Never hardcode keys or store them in plain text. Here are some strategies:

Method Pros Cons
Environment Variables Easy to set up, OS-managed Can be read by process with access
Key Management Service (KMS) Highly secure, audited access Requires external service (e.g., AWS KMS)
Hardware Security Module (HSM) Maximum security, tamper-resistant Expensive, complex setup

For most Go apps, a KMS like AWS KMS or Google Cloud KMS is a good balance. Here’s an example using AWS KMS (simplified, assuming AWS SDK is set up):

package main

import (
    "context"
    "fmt"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/kms"
)

func GenerateDataKey() ([]byte, error) {
    cfg, err := config.LoadDefaultConfig(context.TODO())
    if err != nil {
        return nil, err
    }

    client := kms.NewFromConfig(cfg)
    input := &kms.GenerateDataKeyInput{
        KeyId:   aws.String("alias/my-key"),
        KeySpec: aws.String("AES_256"),
    }

    result, err := client.GenerateDataKey(context.TODO(), input)
    if err != nil {
        return nil, err
    }

    return result.Plaintext, nil
}

func main() {
    key, err := GenerateDataKey()
    if err != nil {
        panic(err)
    }
    fmt.Printf("Generated key: %x\n", key)

    // Output:
    // Generated key: <32-byte key in hex>
}
Enter fullscreen mode Exit fullscreen mode

This fetches a 256-bit key from AWS KMS, which you can use for AES encryption. The key is only in memory temporarily, and KMS handles secure storage.

AWS KMS documentation

Handling Large Data Efficiently

Encrypting large datasets (e.g., user records or files) can be slow if you’re not careful. AES-GCM is a better choice than AES-CBC for large data because it’s faster and provides authentication. Here’s an example:

package main

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "fmt"
    "io"
)

func EncryptLargeData(data, key []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }

    // Create GCM mode
    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return nil, err
    }

    // Generate nonce
    nonce := make([]byte, gcm.NonceSize())
    if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
        return nil, err
    }

    // Encrypt
    ciphertext := gcm.Seal(nil, nonce, data, nil)
    return append(nonce, ciphertext...), nil
}

func DecryptLargeData(ciphertext, key []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }

    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return nil, err
    }

    nonceSize := gcm.NonceSize()
    nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
    plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
    if err != nil {
        return nil, err
    }

    return plaintext, nil
}

func main() {
    key := make([]byte, 32)
    _, err := io.ReadFull(rand.Reader, key)
    if err != nil {
        panic(err)
    }

    data := []byte("large dataset with sensitive info...")
    encrypted, err := EncryptLargeData(data, key)
    if err != nil {
        panic(err)
    }

    fmt.Printf("Encrypted (hex): %x\n", encrypted)

    decrypted, err := DecryptLargeData(encrypted, key)
    if err != nil {
        panic(err)
    }
    fmt.Printf("Decrypted: %s\n", decrypted)

    // Output:
    // Encrypted (hex): <random hex string>
    // Decrypted: large dataset with sensitive info...
}
Enter fullscreen mode Exit fullscreen mode

AES-GCM is more efficient for large data and ensures data integrity, but you need to manage nonces carefully to avoid reuse.

GCM documentation

Trade-Offs of Runtime Encryption

Encrypting memory isn’t free. Here’s a quick breakdown of the pros and cons:

Aspect Pros Cons
Security Protects against memory attacks Doesn’t prevent all attack vectors
Performance Minimal overhead with modern CPUs Encryption/decryption adds latency
Complexity Libraries like crypto/aes are simple Key management adds complexity

Performance impact depends on your workload. AES is hardware-accelerated on most modern CPUs, but frequent encryption/decryption can still slow things down. Measure your app’s performance with tools like pprof to find bottlenecks.

Real-World Example: Securing API Keys

Let’s put it all together with a realistic scenario: a Go server that handles API keys. You want to store the API key encrypted in memory and decrypt it only when making an HTTP request.

package main

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "fmt"
    "io"
    "net/http"
)

type SecureAPIKey struct {
    encryptedKey []byte
    key          []byte
}

func NewSecureAPIKey(apiKey string, key []byte) (*SecureAPIKey, error) {
    encrypted, err := EncryptString(apiKey, key)
    if err != nil {
        return nil, err
    }
    return &SecureAPIKey{encryptedKey: encrypted, key: key}, nil
}

func (s *SecureAPIKey) GetKey() (string, error) {
    return DecryptString(s.encryptedKey, s.key)
}

// Same EncryptString/DecryptString from earlier
func EncryptString(plaintext string, key []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }
    plaintextBytes := []byte(plaintext)
    padding := aes.BlockSize - len(plaintextBytes)%aes.BlockSize
    for i := 0; i < padding; i++ {
        plaintextBytes = append(plaintextBytes, byte(padding))
    }
    iv := make([]byte, aes.BlockSize)
    if _, err := io.ReadFull(rand.Reader, iv); err != nil {
        return nil, err
    }
    ciphertext := make([]byte, len(plaintextBytes))
    mode := cipher.NewCBCEncrypter(block, iv)
    mode.CryptBlocks(ciphertext, plaintextBytes)
    return append(iv, ciphertext...), nil
}

func DecryptString(ciphertext, key []byte) (string, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return "", err
    }
    iv := ciphertext[:aes.BlockSize]
    ciphertext = ciphertext[aes.BlockSize:]
    plaintext := make([]byte, len(ciphertext))
    mode := cipher.NewCBCDecrypter(block, iv)
    mode.CryptBlocks(plaintext, ciphertext)
    padding := int(plaintext[len(plaintext)-1])
    plaintext = plaintext[:len(plaintext)-padding]
    return string(plaintext), nil
}

func main() {
    key := make([]byte, 32)
    _, err := io.ReadFull(rand.Reader, key)
    if err != nil {
        panic(err)
    }

    secureKey, err := NewSecureAPIKey("my-secret-api-key", key)
    if err != nil {
        panic(err)
    }

    // Simulate an HTTP request
    apiKey, err := secureKey.GetKey()
    if err != nil {
        panic(err)
    }

    req, err := http.NewRequest("GET", "https://api.example.com/data", nil)
    if err != nil {
        panic(err)
    }
    req.Header.Set("Authorization", "Bearer "+apiKey)

    fmt.Println("Request sent with API key")
    // Output:
    // Request sent with API key
}
Enter fullscreen mode Exit fullscreen mode

This keeps the API key encrypted in memory and only decrypts it for the HTTP request. The SecureAPIKey struct ensures you’re not accidentally exposing the plain-text key.

Tips for Production Use

Here are some practical tips for using runtime memory encryption in production:

  • Use a KMS: Don’t roll your own key storage. Services like AWS KMS or HashiCorp Vault are battle-tested.
  • Minimize decryption: Only decrypt data when absolutely necessary to reduce exposure.
  • Monitor performance: Use Go’s pprof to profile encryption overhead.
  • Secure key rotation: Rotate encryption keys regularly and use a KMS to manage them.
  • Zero memory: Overwrite sensitive data after use (though Go’s GC makes this tricky).

For more advanced use cases, libraries like libsodium (via golang.org/x/crypto/nacl) can provide additional memory protection features.

Next Steps for Securing Your Go Apps

Runtime memory encryption is a solid step toward securing sensitive data, but it’s not a silver bullet. Combine it with other practices like secure coding, least-privilege access, and regular security audits. Test your encryption implementation thoroughly to ensure it’s working as expected, and always benchmark performance to avoid surprises in production.

If you’re building a high-security app, consider diving deeper into libraries like libsodium or exploring hardware-based solutions like Intel SGX for even stronger protection. For most Go developers, starting with AES and a KMS will cover 90% of use cases. Experiment with the code examples above, measure their impact, and adapt them to your needs.

Top comments (0)