Encrypted integer IDs for Ash resources using Feistel cipher
Database Support: PostgreSQL only (requires AshPostgres data layer and PostgreSQL database)
Sequential IDs (1, 2, 3...) leak business information. This library provides a declarative DSL to configure Feistel cipher encryption in your Ash resources, transforming sequential integers into non-sequential, unpredictable values automatically via database triggers.
Key Benefits:
- Secure IDs without UUIDs: Hide sequential patterns while keeping efficient integer IDs with adjustable bit size per column
- Deterministic encryption: Same insertion order always produces same encrypted ID (consistent seed data in dev/staging environments, unlike UUIDs/random integers)
- Automatic encryption: Database triggers handle encryption transparently
- Collision-free: One-to-one mapping
For detailed information about the Feistel cipher algorithm, how it works, security properties, and performance benchmarks, see the feistel_cipher library documentation.
mix igniter.install ash_feistel_cipherYou can customize the installation with the following options:
--repoor-r: Specify an Ecto repo for FeistelCipher to use.--functions-prefixor-p: Specify the PostgreSQL schema where the FeistelCipher functions will be created, defaults topublic.--functions-saltor-s: Specify the constant value used in the Feistel cipher algorithm. A random value is generated by default if not specified. Must be between 0 and 2^31-1.
⚠️ Security Note: A cryptographically random salt is generated by default for each project. This ensures that encryption patterns cannot be analyzed across different projects. Never use the same salt across multiple production projects.
If you need more control over the installation process, you can install manually:
-
Add
ash_feistel_cipherto your list of dependencies inmix.exs:def deps do [ {:ash_feistel_cipher, "~> 1.0"} ] end
-
Fetch the dependencies:
mix deps.get
-
Install FeistelCipher separately:
mix igniter.install feistel_cipher --repo MyApp.Repo
-
Add
:ash_feistel_cipherto your formatter configuration in.formatter.exs:[ import_deps: [:ash_feistel_cipher] ]
See UPGRADE.md for the migration guide.
Add AshFeistelCipher extension to your Ash resource and use the declarative DSL:
defmodule MyApp.Post do
use Ash.Resource,
data_layer: AshPostgres.DataLayer,
extensions: [AshFeistelCipher]
postgres do
table "posts"
repo MyApp.Repo
end
attributes do
integer_sequence :seq
encrypted_integer_primary_key :id, from: :seq
attribute :title, :string, allow_nil?: false
end
endGenerate the migration:
mix ash.codegen create_postThis creates a migration with database triggers that automatically encrypt seq into id.
Generated Migration Example:
defmodule MyApp.Repo.Migrations.CreatePost do
use Ecto.Migration
def up do
create table(:posts) do
add :seq, :bigserial, null: false
add :id, :bigint, null: false, primary_key: true
add :title, :string, null: false
end
# Automatically generates trigger for seq -> id encryption
execute(
FeistelCipher.up_for_trigger("public", "posts", "seq", "id",
time_bits: 15,
time_bucket: 86400,
encrypt_time: false,
data_bits: 38,
key: 1_984_253_769,
rounds: 16,
functions_prefix: "public"
)
)
end
def down do
execute(FeistelCipher.down_for_trigger("public", "posts", "seq", "id"))
drop table(:posts)
end
endHow it works:
When you create a record, the database trigger automatically encrypts the sequential seq value:
# Create a post - seq and id are auto-generated
post = MyApp.Post.create!(%{title: "Hello World"})
# => %MyApp.Post{seq: 1, id: 3_141_592_653, title: "Hello World"}
# The encrypted id is deterministic and collision-free
post2 = MyApp.Post.create!(%{title: "Second Post"})
# => %MyApp.Post{seq: 2, id: 2_718_281_828, title: "Second Post"}
# You can query by the encrypted id
MyApp.Post.get!(3_141_592_653)
# => %MyApp.Post{seq: 1, id: 3_141_592_653, title: "Hello World"}Custom ID range with data_bits:
attributes do
integer_sequence :seq
encrypted_integer_primary_key :id,
from: :seq,
data_bits: 32 # ~4 billion IDs (default: 38 = ~275 billion)
endDisable time prefix (backward compatible with v0.x):
attributes do
integer_sequence :seq
encrypted_integer_primary_key :id, from: :seq, time_bits: 0, data_bits: 52
endMultiple encrypted columns from same source:
attributes do
integer_sequence :seq
encrypted_integer_primary_key :id, from: :seq
encrypted_integer :referral_code, from: :seq, allow_nil?: false
attribute :title, :string, allow_nil?: false
end
# Each column uses a different encryption key, generating unique values:
# => %MyApp.Post{seq: 1, id: 3_141_592_653, referral_code: 8_237_401_928, title: "Hello"}Using any integer attribute with from (e.g. optional postal code):
attributes do
attribute :postal_code, :integer, allow_nil?: true
encrypted_integer :encrypted_postal_code, from: :postal_code, allow_nil?: true
endinteger_sequence: Auto-incrementing bigserial column
integer_sequence :seqencrypted_integer: Encrypted integer column
The base form for encrypted columns. Automatically sets writable?: false, generated?: true
encrypted_integer :id, from: :seq, primary_key?: true, allow_nil?: false, public?: true
encrypted_integer :referral_code, from: :seqencrypted_integer_primary_key: Shorthand for encrypted primary keys
Convenience helper equivalent to encrypted_integer with primary_key?: true, allow_nil?: false, public?: true pre-set.
encrypted_integer_primary_key :id, from: :seq
encrypted_integer_primary_key :id, from: :seq, data_bits: 32Common Options for Encrypted Columns:
Required:
from: Integer attribute to encrypt (can be any integer attribute)
Optional (
time_bits(default: 15): Time prefix bits for backup optimization. Set to 0 for no time prefixtime_bucket(default: 86400): Time bucket size in secondstime_offset(default: 0): Time offset in seconds applied before bucket calculation- Formula:
time_value = floor((epoch + time_offset) / time_bucket) - Sign convention: positive values move the boundary earlier in local time; negative values move it later
- Example:
time_bucket: 86400,time_offset: 21600shifts daily boundary from00:00 UTCto18:00 UTC(03:00 KST)
- Formula:
- With defaults (
time_bits: 15,time_bucket: 86400,encrypt_time: false), the time prefix wraps after about 89 years 9 months encrypt_time(default: false): Whether to encrypt the time prefixdata_bits(default: 38): Data encryption bit size (must be even)key: Custom encryption key (auto-generated from table/column names if not provided)rounds(default: 16): Number of Feistel rounds (higher = more secure but slower)functions_prefix(default: "public"): PostgreSQL schema where feistel functions are installed
Important:
allow_nil?on encrypted column must matchfromattribute's nullability- For primary keys, prefer
encrypted_integer_primary_keyfor cleaner syntax
Without time_offset, daily time_bucket boundaries are anchored to UTC midnight. In local operations this can split one business day into two buckets at awkward local times (for example, evening in the Americas or early morning in Europe).
time_offset allows teams to keep the same bucket size (for example, one day) while moving the boundary to an operational cutover hour (for example, 03:00 local). This is especially useful when encrypt_time: true is enabled and continuity must be controlled by configuration, not by reading the encrypted prefix.
In this DSL, time_offset is added to epoch before bucketing. So time_offset: 21600 (not -21600) is the correct setting for a 03:00 KST daily boundary.
MIT