Retool automatically encrypts resource credentials and configuration variables using an ENCRYPTION_KEY at rest. For encrypting user data fields, implement AES-GCM encryption in a JS Query using the browser's Web Crypto API. Store your encryption keys in Retool's environment configuration variables (marked as secret), never in app code or transformer logic.
| Fact | Value |
|---|---|
| Tool | Retool |
| Difficulty | Advanced |
| Time required | 30-45 min |
| Compatibility | Retool Cloud and Self-hosted |
| Last updated | March 2026 |
Three Layers of Data Encryption in Retool
Data encryption in Retool happens at three levels. First, Retool itself encrypts all stored resource credentials (database passwords, API keys) using an ENCRYPTION_KEY — on self-hosted deployments you set this key; on Retool Cloud it is managed for you. Second, all data in transit between the Retool server, user browsers, and your databases is protected by TLS. Third, for encrypting specific data fields at the application layer (e.g. SSNs, PII), you implement custom encryption using the Web Crypto API in JS Queries.
This tutorial focuses on the third layer — custom field-level encryption — which is what most teams implementing compliance-related data handling need to build. We cover AES-GCM symmetric encryption, secure key management via Retool environment variables, and the encrypt/decrypt workflow for storing and retrieving sensitive data.
Prerequisites
- Retool app with a database resource (PostgreSQL or similar) for storing encrypted data
- Intermediate JavaScript knowledge including async/await and Promises
- Understanding of Retool JS Queries and environment variables
- For self-hosted: access to Retool's ENCRYPTION_KEY environment variable
- Familiarity with Retool's query editor and Code tab
Step-by-step guide
Understand What Retool Encrypts Automatically
Before implementing custom encryption, understand what Retool handles automatically. On self-hosted Retool, set ENCRYPTION_KEY as a Docker/Kubernetes environment variable. Retool uses this key to encrypt: all resource connection strings and passwords, API keys stored in resources, configuration variables marked as 'secret', and OAuth tokens for connected services. On Retool Cloud, this is handled automatically — you do not need to set ENCRYPTION_KEY. Credentials are encrypted at rest using AES-256. What Retool does NOT encrypt: the data returned by your queries (e.g. rows from your database), data displayed in tables and forms, user-entered data before it is written to your database. This is where custom application-layer encryption is needed.
1// For self-hosted Retool (docker-compose.yml):2// ENCRYPTION_KEY=your-random-32-byte-hex-string3// Generate with: openssl rand -hex 3245// Environment variable in Retool UI:6// Settings → Configuration Variables → Add7// Name: ENCRYPTION_KEY (mark as secret)8// Value: your-key-here910// In a resource query, Retool automatically decrypts connection11// credentials when making database connections — you don't see the key.Expected result: You understand what Retool handles automatically and what requires custom implementation.
Store Your Application Encryption Key in Configuration Variables
Never hardcode an encryption key in your app code or transformer logic. Instead, create a Retool configuration variable marked as 'secret'. In Settings → Configuration Variables, add a variable named APP_ENCRYPTION_KEY with your key value. Mark it as 'secret' so it is encrypted at rest and never exposed in the Retool UI after saving. Access it in queries using {{ retoolContext.configVars.APP_ENCRYPTION_KEY }} — note this is server-side only in resource queries. For JS Queries running client-side, you need to either pass the key via a server-side query result or use a key derivation approach.
1// Add in Settings → Configuration Variables:2// Name: APP_ENCRYPTION_KEY (Secret: YES)3// Value: generate with: crypto.getRandomValues(new Uint8Array(32))4// then convert to base6456// Generate a key in browser console for initial setup:7const key = await window.crypto.subtle.generateKey(8 { name: 'AES-GCM', length: 256 },9 true, // extractable10 ['encrypt', 'decrypt']11);12const exported = await window.crypto.subtle.exportKey('raw', key);13const base64Key = btoa(String.fromCharCode(...new Uint8Array(exported)));14console.log('Encryption key (store in config var):', base64Key);1516// A key retrieved from a server-side query:17// SELECT decrypted_key FROM encryption_keys WHERE active = true18// (key stored server-side, not client-accessible)Expected result: A secret configuration variable stores your application encryption key, not exposed in code.
Create an Encrypt JS Query Using Web Crypto API
Create a JS Query named 'encryptField'. This query takes a plaintext value, retrieves the key from a key-loading query, and returns the encrypted value as a base64 string that can be stored in your database. The Web Crypto API's AES-GCM algorithm requires a unique initialization vector (IV) per encryption — store the IV alongside the ciphertext since you need it for decryption.
1// JS Query: encryptField2// Input: encryptInput.value (the plaintext to encrypt)3// Requires: getEncryptionKey query that returns the base64 key45async function encryptData(plaintext, base64Key) {6 // Decode the base64 key7 const keyBytes = Uint8Array.from(atob(base64Key), c => c.charCodeAt(0));89 // Import the key for use with Web Crypto10 const cryptoKey = await window.crypto.subtle.importKey(11 'raw',12 keyBytes,13 { name: 'AES-GCM' },14 false,15 ['encrypt']16 );1718 // Generate a random 12-byte IV (required for AES-GCM)19 const iv = window.crypto.getRandomValues(new Uint8Array(12));2021 // Encode plaintext22 const encodedText = new TextEncoder().encode(plaintext);2324 // Encrypt25 const ciphertext = await window.crypto.subtle.encrypt(26 { name: 'AES-GCM', iv },27 cryptoKey,28 encodedText29 );3031 // Combine IV + ciphertext and encode as base64 for storage32 const combined = new Uint8Array(iv.length + ciphertext.byteLength);33 combined.set(iv, 0);34 combined.set(new Uint8Array(ciphertext), iv.length);3536 return btoa(String.fromCharCode(...combined));37}3839// Get the encryption key from a server-side query40const keyResult = await getEncryptionKey.trigger();41const base64Key = keyResult.data[0].key_value;4243// Encrypt the input value44const encrypted = await encryptData(encryptInput.value, base64Key);45return { encrypted };Expected result: The encryptField query returns an encrypted base64 string ready for database storage.
Create a Decrypt JS Query
Create a JS Query named 'decryptField' that reverses the encryption. It reads the base64-encoded combined IV+ciphertext from the database, splits out the IV, and decrypts to return the original plaintext.
1// JS Query: decryptField2// Input: the encrypted base64 string from database3// Usage: await decryptField.trigger({ additionalScope: { encryptedValue: row.ssn } })45async function decryptData(base64Combined, base64Key) {6 // Decode the key7 const keyBytes = Uint8Array.from(atob(base64Key), c => c.charCodeAt(0));89 const cryptoKey = await window.crypto.subtle.importKey(10 'raw',11 keyBytes,12 { name: 'AES-GCM' },13 false,14 ['decrypt']15 );1617 // Decode the combined IV + ciphertext18 const combined = Uint8Array.from(atob(base64Combined), c => c.charCodeAt(0));1920 // Extract IV (first 12 bytes) and ciphertext (rest)21 const iv = combined.slice(0, 12);22 const ciphertext = combined.slice(12);2324 // Decrypt25 const decrypted = await window.crypto.subtle.decrypt(26 { name: 'AES-GCM', iv },27 cryptoKey,28 ciphertext29 );3031 return new TextDecoder().decode(decrypted);32}3334// Get key (server-side)35const keyResult = await getEncryptionKey.trigger();36const base64Key = keyResult.data[0].key_value;3738// encryptedValue is passed via additionalScope or from a component39const plaintext = await decryptData(encryptedValue, base64Key);40return { plaintext };Expected result: The decryptField query returns the original plaintext from the encrypted database value.
Wire Encryption into a Save Form Flow
In practice, encrypt sensitive fields immediately before writing to the database. Create a Button event handler that runs a JS Query chain: (1) validate the form, (2) encrypt the sensitive field, (3) run the INSERT/UPDATE query with the encrypted value. The non-sensitive fields go directly to the query without encryption. Never log or display plaintext sensitive data in Retool components after decryption unless absolutely necessary.
1// JS Query: saveUserWithEncryption2// Triggered by 'Save' button34// Step 1: Encrypt the SSN5const encryptResult = await encryptField.trigger({6 additionalScope: { plaintext: ssnInput.value }7});8const encryptedSSN = encryptResult.encrypted;910// Step 2: Run the INSERT with encrypted SSN11await saveUserQuery.trigger({12 additionalScope: {13 firstName: firstNameInput.value,14 lastName: lastNameInput.value,15 email: emailInput.value,16 encryptedSSN: encryptedSSN // stored as encrypted base6417 }18});1920// Step 3: Show success notification21utils.showNotification({22 title: 'User saved',23 description: 'Sensitive data encrypted and stored securely.',24 notificationType: 'success',25 duration: 326});2728// SQL Query: saveUserQuery29// INSERT INTO users (first_name, last_name, email, ssn_encrypted)30// VALUES ({{ firstName }}, {{ lastName }}, {{ email }}, {{ encryptedSSN }})Expected result: The form saves non-sensitive fields in plaintext and sensitive fields as encrypted base64 strings.
Handle Encryption Key Rotation
When you need to rotate your encryption key (a security best practice), you must re-encrypt all existing data. Create a JS Query that fetches all encrypted records, decrypts each with the old key, re-encrypts with the new key, and updates the database. Run this in batches to avoid timeout. This is a one-time maintenance operation, not part of normal app flow.
1// JS Query: rotateEncryptionKeys (admin operation only)2// Run manually when rotating keys34const records = await getEncryptedRecords.trigger();5const oldKey = retoolContext.configVars.OLD_ENCRYPTION_KEY; // before rotation6const newKey = retoolContext.configVars.APP_ENCRYPTION_KEY; // new key78const batchSize = 100;9let updated = 0;1011for (let i = 0; i < records.data.length; i += batchSize) {12 const batch = records.data.slice(i, i + batchSize);1314 for (const record of batch) {15 // Decrypt with old key16 const plaintext = await decryptWithKey(record.ssn_encrypted, oldKey);17 // Re-encrypt with new key18 const newEncrypted = await encryptWithKey(plaintext, newKey);19 // Update record20 await updateEncryptedField.trigger({21 additionalScope: { id: record.id, newEncrypted }22 });23 updated++;24 }25}2627return { message: `Rotated ${updated} records` };Expected result: All encrypted records are re-encrypted with the new key after rotation.
Verify TLS for Data in Transit
Ensure all data is encrypted in transit between your users' browsers, Retool's servers, and your databases. For Retool Cloud, TLS is enforced automatically. For self-hosted: configure HTTPS on your reverse proxy (nginx or similar), use SSL certificates for all database connections by enabling SSL mode in resource configuration, and ensure all API resources use HTTPS URLs. In the Retool resource editor, look for the SSL/TLS settings for database resources — typically a 'SSL mode' dropdown. Set it to 'require' or 'verify-full' for production databases.
Expected result: All connections between Retool and your infrastructure use TLS, protecting data in transit.
Complete working example
1// Complete encrypt-then-store pattern for sensitive fields2// Triggered from a form Save button34// ---- Helper functions ----56async function getKey(base64Key) {7 const keyBytes = Uint8Array.from(atob(base64Key), c => c.charCodeAt(0));8 return window.crypto.subtle.importKey(9 'raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']10 );11}1213async function encrypt(plaintext, base64Key) {14 const key = await getKey(base64Key);15 const iv = window.crypto.getRandomValues(new Uint8Array(12));16 const ct = await window.crypto.subtle.encrypt(17 { name: 'AES-GCM', iv },18 key,19 new TextEncoder().encode(plaintext)20 );21 const combined = new Uint8Array(12 + ct.byteLength);22 combined.set(iv);23 combined.set(new Uint8Array(ct), 12);24 return btoa(String.fromCharCode(...combined));25}2627async function decrypt(base64Combined, base64Key) {28 const key = await getKey(base64Key);29 const combined = Uint8Array.from(atob(base64Combined), c => c.charCodeAt(0));30 const iv = combined.slice(0, 12);31 const ct = combined.slice(12);32 const plaintext = await window.crypto.subtle.decrypt(33 { name: 'AES-GCM', iv }, key, ct34 );35 return new TextDecoder().decode(plaintext);36}3738// ---- Main flow ----3940// Load encryption key from server-side query (never expose in client code)41const keyResult = await loadEncryptionKey.trigger();42if (!keyResult.data?.[0]?.key_value) {43 throw new Error('Failed to load encryption key');44}45const base64Key = keyResult.data[0].key_value;4647// Encrypt sensitive fields48const encryptedSSN = await encrypt(ssnInput.value, base64Key);49const encryptedDOB = await encrypt(dobInput.value, base64Key);5051// Save to database with encrypted values52await insertPatientQuery.trigger({53 additionalScope: {54 firstName: firstNameInput.value,55 lastName: lastNameInput.value,56 email: emailInput.value,57 encryptedSSN,58 encryptedDOB59 }60});6162utils.showNotification({63 title: 'Record saved',64 description: 'Sensitive fields encrypted with AES-256-GCM.',65 notificationType: 'success'66});6768return { success: true };Common mistakes when implementing Data Encryption in Retool
Why it's a problem: Storing the encryption key as a non-secret configuration variable or directly in transformer code
How to avoid: Create the configuration variable with the 'Secret' checkbox enabled in Settings → Configuration Variables. Secret variables are encrypted at rest and not visible in the Retool UI after saving.
Why it's a problem: Reusing the same IV for multiple encryptions
How to avoid: Generate a fresh random IV for each encryption call: window.crypto.getRandomValues(new Uint8Array(12)). Reusing IVs with AES-GCM is a critical security vulnerability.
Why it's a problem: Calling decryptField.trigger() inside a transformer expecting async support
How to avoid: Transformers are synchronous and cannot use await or call async queries. All encryption/decryption must happen in JS Queries, which fully support async/await.
Why it's a problem: Displaying decrypted sensitive values in a table where all users can see them
How to avoid: Decrypt and display sensitive values only on explicit user action (e.g. a 'Reveal' button click) in a modal, and only after confirming the user's role has access. Use table1.selectedRow in a modal rather than showing in the main table column.
Best practices
- Never hardcode encryption keys in transformer code, JS Queries, or {{ }} expressions — always use Retool configuration variables marked as secret.
- Use a unique IV (initialization vector) for every encryption operation — reusing IVs with AES-GCM compromises security.
- Store encrypted values alongside a key version identifier so you can handle key rotation without re-encrypting everything at once.
- Minimize plaintext exposure — decrypt only when displaying to an authorized user, not on every page load or in list views.
- For PCI-DSS, HIPAA, or similar compliance, use a dedicated KMS (AWS KMS, HashiCorp Vault) rather than storing keys in Retool config variables.
- Use TLS mode 'require' or 'verify-full' in all Retool database resource configurations for production databases.
- Log encryption and decryption events in your audit system (not the plaintext values) to maintain a compliance audit trail.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a Retool admin tool that needs to store encrypted SSNs in a PostgreSQL database. I want to implement AES-256-GCM encryption using the browser's Web Crypto API in Retool JS Queries. Write two JS Queries: 'encryptField' that takes a plaintext value and returns a base64-encoded IV+ciphertext string, and 'decryptField' that takes the base64 string and returns the plaintext. The encryption key should be loaded from a server-side query, not hardcoded. Include proper error handling and explain where to store the key.
Create two JS Queries in my Retool app: (1) 'encryptField' using window.crypto.subtle with AES-GCM 256-bit encryption — take plaintext from ssnInput.value, load the base64 key from a loadEncryptionKey query, return the encrypted base64 string. (2) 'decryptField' that reverses the process. Both queries must use async/await and generate a random IV per encryption. Explain how to store the key in Retool configuration variables marked as secret.
Frequently asked questions
Does Retool Cloud encrypt my database data automatically?
Retool Cloud encrypts stored credentials and configuration variables at rest, and all connections use TLS in transit. However, Retool does not encrypt the actual data in your database — it queries and displays it as-is. For encrypting specific sensitive fields (like SSNs or credit card numbers), you must implement field-level encryption in your Retool app or database.
Can I use ENCRYPTION_KEY in self-hosted Retool to encrypt user data?
Retool's ENCRYPTION_KEY is used internally by Retool to encrypt its own stored credentials and configuration. It is not directly accessible in JS Queries or transformers for application-level encryption. For encrypting user data fields, create a separate application encryption key stored as a secret configuration variable and implement encryption in JS Queries.
Can I run the Web Crypto API encryption inside a Retool transformer?
No. Transformers are synchronous and cannot use await or Promises. The Web Crypto API is entirely async. All encryption and decryption logic must be in JS Queries, which fully support async/await.
What encryption algorithm should I use for HIPAA compliance in Retool?
HIPAA requires AES-128 minimum, with AES-256 being the standard recommendation. Use AES-256-GCM via the Web Crypto API as shown in this tutorial — it provides both encryption and authentication (ensuring data integrity). For regulated environments, also ensure keys are stored in a FIPS 140-2 validated KMS rather than Retool config variables.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation