entcrypt provides automatic field-level encryption for ent schemas. Fields
are encrypted before writes (via hooks) and decrypted after reads (via
interceptors) using AES-256-GCM. Clients always see plaintext values.
- Automatic encryption/decryption — encrypt on write, decrypt on read
- Annotation-based — mark fields with
entcrypt.EncryptedField{} - AES-256-GCM — authenticated encryption with random nonces
- Pluggable key providers — static keys, env vars, or your own
- No schema changes — encrypted data stays in the same string column
- entc extension — auto-discovers encrypted fields during codegen
| Type | Description |
|---|---|
string |
Only string fields can be encrypted. The ciphertext is stored in the same column, so the field type must be compatible. Make sure your columns are long enough to accommodate the encrypted data. |
go get github.com/k0in/entcryptAnnotate any string field with entcrypt.EncryptedField{}:
// ent/schema/user.go
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
"github.com/k0in/entcrypt"
)
type User struct{ ent.Schema }
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("name"),
field.String("email").Annotations(entcrypt.EncryptedField{}),
field.String("ssn").Annotations(entcrypt.EncryptedField{}),
}
}// cmd/entc/main.go
package main
import (
"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
"github.com/k0in/entcrypt"
)
func main() {
entc.Generate("./ent/schema", &gen.Config{},
entc.Extensions(entcrypt.Extension{}),
)
}Now run go run cmd/entc/main.go. The extension scans all schemas, discovers
EncryptedField annotations, and emits an entcrypt_gen.go file that
registers the mapping automatically.
func main() {
ctx := context.Background()
// Create an encrypter from a hex-encoded AES-256 key.
+ key, _ := hex.DecodeString(os.Getenv("ENTCRYPT_KEY"))
+ enc, err := entcrypt.New(&entcrypt.StaticKeyProvider{Key: key})
+ if err != nil {
+ log.Fatal(err)
+ }
// Open the ent client.
client, err := ent.Open(dialect.SQLite, "file:ent.db?_fk=1")
if err != nil {
log.Fatal(err)
}
defer client.Close()
// Install the encryption hook (writes) and decryption interceptor (reads).
+ client.Use(entx.EncryptHookFunc(enc))
+ client.Intercept(entx.DecryptInterceptor(enc))
// Create — field values are encrypted in the DB.
u, err := client.User.Create().
SetName("Alice").
SetEmail("alice@example.com").
SetSsn("000-00-0000").
Save(ctx)
// u.Email → "alice@example.com" (plaintext on the returned value)
// Get — values are decrypted automatically.
u, err = client.User.Get(ctx, u.ID)
// u.Email → "alice@example.com"
// Query — all results are decrypted.
all, err := client.User.Query().All(ctx)
// all[0].Email → "alice@example.com"
}export ENTCRYPT_KEY=$(openssl rand -hex 32)// StaticKeyProvider - Set the key directly in code (loaded from env, flag, or config)
key, _ := hex.DecodeString(os.Getenv("ENTCRYPT_KEY"))
enc, _ := entcrypt.New(&entcrypt.StaticKeyProvider{Key: key})
// EnvKeyProvider - Reads a hex-encoded key from an environment variable automatically
enc, _ := entcrypt.New(&entcrypt.EnvKeyProvider{EnvVar: "ENTCRYPT_KEY"})
// Custom provider - implement the KeyProvider interface for your own key source (Vault, AWS KMS, etc.)
type VaultProvider struct { ... }
func (p *VaultProvider) EncryptionKey() ([]byte, error) {
return p.fetchKey()
}Keys must be exactly 32 bytes after decoding. For hex-encoded keys, use 64
hex characters, for example openssl rand -hex 32.
You don't have to use the entcrypt.Extension{} with a custom cmd/entc/main.go.
If you prefer standard go generate ./ent, just register the encrypted fields
manually:
// ent/entcrypt_gen.go
package ent
import "github.com/k0in/entcrypt"
func init() {
entcrypt.Register("User", "email", "ssn")
}Keep this list in sync with your schema annotations. That's the only extra step.
If Register is called more than once for the same entity, fields are merged
and duplicate field names are ignored.
See examples/noentc/ for a complete working example.
Built with the goexperiment.runtimesecret tag, entcrypt uses runtime/secret to protect decrypted plaintext from GC scanning:
go build -tags goexperiment.runtimesecret ./...Without the tag, entcrypt falls back to standard decryption.
Values that do not start with the v1:AES-256-GCM: storage header fail
decryption by default. This keeps new projects fail-closed: encrypted-field
database values must be authenticated AES-GCM ciphertext before they are returned
to application code.
For in-place migrations of existing plaintext columns, opt in explicitly:
enc, err := entcrypt.New(
&entcrypt.EnvKeyProvider{EnvVar: "ENTCRYPT_KEY"},
entcrypt.WithPlaintextFallback(),
)Use this only while migrating legacy rows. Missing-header values do not receive AES-GCM integrity verification, so migrate plaintext rows to encrypted values and disable the fallback once the migration is complete.
entcrypt.ReEncrypt(old, new, ciphertext) decrypts with the old key and
re-encrypts with the new key without exposing the plaintext to the caller.
This is useful for migration scripts and scheduled key rotation.
oldEnc, _ := entcrypt.New(&entcrypt.EnvKeyProvider{EnvVar: "OLD_KEY"})
newEnc, _ := entcrypt.New(&entcrypt.EnvKeyProvider{EnvVar: "NEW_KEY"})
// Re-encrypt all rows for a given entity.
for _, row := range rows {
newCiphertext, err := entcrypt.ReEncrypt(oldEnc, newEnc, row.Email)
// ... write newCiphertext back to the database
}See examples/reencrypt/ for a complete runnable
example that also covers plaintext-to-encrypted migration with fallback.
Encrypted output is approximately 1.5 × plaintext_len + 44 bytes (fixed
overhead for the v1:AES-256-GCM: header and base64 encoding). Plan your
database columns accordingly:
| Plaintext length | Approximate ciphertext length | Recommended minimum column type |
|---|---|---|
| 50 | 119 | VARCHAR(128) |
| 100 | 194 | VARCHAR(256) |
| 255 | 427 | VARCHAR(512) |
| 500 | 794 | VARCHAR(1024) |
| 1000 | 1544 | VARCHAR(2048) or TEXT |
Tip
When adding encryption to an existing schema, ensure the target column is
large enough for the encrypted output, especially if an existing VARCHAR
constraint was sized for plaintext values.
Encrypted fields cannot be queried by plaintext with normal ent predicates.
Because AES-GCM uses a random nonce for each write, the same plaintext encrypts
to a different ciphertext each time. A predicate such as
Where(user.EmailEQ("alice@example.com")) compares the plaintext value against
the randomized ciphertext stored in the database, so it will not match.
For lookup fields, store a separate normalized lookup value such as a keyed hash or another application-specific index column, and keep the encrypted field for confidential reads.
Examples are provided in the examples/ directory:
| Directory | Approach | Codegen command |
|---|---|---|
simple |
entcrypt.Extension{} with entc.Generate() (auto-register) |
go run ./cmd/entc/ |
noentc |
Standard go generate (manual register) |
go generate ./ent |
complex |
Multi-schema entcrypt.Extension{} setup with edges and encrypted predicates proof |
go run ./cmd/entc/ |
reencrypt |
Standalone key rotation and legacy-data migration with ReEncrypt |
go run . |
The simple and noentc examples produce the same runtime behaviour — the difference is only in how encrypted fields are registered. The complex example exercises multiple schemas, edges, raw encrypted storage checks, and the fact that plaintext predicates do not match randomized encrypted values.
| Layer | Component | What it does |
|---|---|---|
| Codegen | entcrypt.Extension{} in entc.Generate() |
Scans schemas, auto-discovers EncryptedField annotations, emits a registry file |
| Schema | entcrypt.EncryptedField{} annotation |
Marks a string field as encrypted |
| Hook | entx.EncryptHookFunc(enc) |
Encrypts field values before DB writes |
| Interceptor | entx.DecryptInterceptor(enc) |
Decrypts field values after DB reads |
- Codegen time: The
entcrypt.Extensionhook fires duringentc.Generate(), inspects everyField.AnnotationsforEncryptedField, and writes anentcrypt_gen.gofile with aninit()that registers the mapping. No manual config needed. - Write path: The
EncryptHookFunchook interceptsCreateandUpdatemutations, looks up the entity type in the registry, encrypts the matching string fields, then passes the ciphertext to the database. - Read path: The
DecryptInterceptorinterceptor runs after every query (Get,Query,QueryX), decrypts encrypted fields, and returns the plaintext to the caller. - Return values: The hook also decrypts the value returned by
Save,Update, andUpdateOneso callers always see plaintext.
Every encrypted value is stored as a plain-text header followed by the base64-encoded ciphertext. This makes the format self-describing and easy to identify when inspecting the database directly:
v1:AES-256-GCM:<base64>
| Part | Example | Description |
|---|---|---|
| Version | v1 |
Format version for future migration |
| Algorithm | AES-256-GCM |
Cipher suite used |
| Body | <base64> |
Nonce + AES-GCM authenticated ciphertext, base64-encoded |
-- Inspecting encrypted data in the database:
sqlite3 ent.db "SELECT email, ssn FROM users;"
-- email → "v1:AES-256-GCM:qK4RmjH...#..."
-- ssn → "v1:AES-256-GCM:8LpX2yT...#..."| Package | Type / Function | Description |
|---|---|---|
entcrypt |
EncryptedField{} |
Schema annotation for encrypted string fields |
entcrypt |
Extension{} |
entc extension that auto-discovers encrypted fields during codegen |
entcrypt |
New(provider) |
Creates an Encrypter from a key provider |
entcrypt |
ReEncrypt(old, new, ciphertext) |
Re-encrypts data from one key to another; plaintext stays inside the function |
entcrypt |
StaticKeyProvider |
Key provider with a static AES key |
entcrypt |
EnvKeyProvider |
Key provider reading from an env var |
entcrypt |
WithPlaintextFallback() |
Explicit migration option for legacy plaintext rows |
entx |
EncryptHookFunc(enc) |
Returns an ent.Hook that encrypts on write |
entx |
DecryptInterceptor(enc) |
Returns an ent.Interceptor that decrypts on read |