Building a Secure, Production-Ready IndexedDB Key–Value Store in Angular (With AES Encryption)
Technology Enthusiast and voracious reader with a demonstrated history of working in the computer software industry. Skilled in PHP, JavaScript, NodeJS, Angular, MySQL, MongoDB, Web3, Product Development, Project Management, and Teamwork.
Modern enterprise Angular applications increasingly rely on offline-first capabilities, client-side caching, and secure local persistence. While browsers provide powerful primitives like IndexedDB and the Web Crypto API, they are often underutilized—or implemented inconsistently.
In this post, we’ll design and implement a production-ready, generic IndexedDB key–value service for Angular, featuring:
Strong typing with generics
AES-GCM encryption at rest
Environment-based security toggles
Clean Angular dependency-injection design
Zero third-party storage or crypto libraries
This pattern is suitable for enterprise-scale Angular applications that require secure, reusable, and maintainable client storage.
Why Not Just Use LocalStorage?
Before diving in, it’s worth clarifying why IndexedDB is the correct choice here:
| Feature | LocalStorage | IndexedDB |
| Storage size | ~5MB | Hundreds of MBs |
| Async | ❌ No | ✅ Yes |
| Structured data | ❌ No | ✅ Yes |
| Transactions | ❌ No | ✅ Yes |
| Encryption-ready | ⚠️ Limited | ✅ Yes |
| Offline-first | ❌ Weak | ✅ Strong |
For anything beyond trivial flags or preferences, IndexedDB is the correct foundation.
Architecture Overview
Design Goals
This implementation focuses on:
Generic key–value storage (no schema lock-in)
Encrypted data at rest
Strong typing with TypeScript generics
Single injectable service
Production-only encryption
No runtime overhead in development
Technology Stack
IndexedDB (native browser API)
Web Crypto API (
AES-GCM,PBKDF2)Angular Dependency Injection
Environment-based configuration
No Dexie.js. No crypto libraries. No abstractions you can’t audit.
1. Environment Configuration
Encryption must be explicit and environment-driven. Development should be fast and debuggable; production should be secure.
environment.ts
export const environment = {
production: false,
indexedDbEncryptionKey: 'DEV_SECRET_KEY'
};
environment.prod.ts
export const environment = {
production: true,
indexedDbEncryptionKey: 'PROD_SECRET_KEY_CHANGE_THIS'
};
⚠️ Important: In real production systems, encryption keys should be injected via CI/CD secrets or runtime config—not hardcoded.
2. Crypto Utility (AES-GCM with Web Crypto API)
We use AES-GCM, which provides:
Confidentiality
Integrity
Authentication
Key derivation is handled using PBKDF2 + SHA-256.
crypto.util.ts
export class CryptoUtil {
private static encoder = new TextEncoder();
private static decoder = new TextDecoder();
static async generateKey(secret: string): Promise<CryptoKey> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
this.encoder.encode(secret),
'PBKDF2',
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: this.encoder.encode('indexeddb-salt'),
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
static async encrypt(data: any, secret: string): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await this.generateKey(secret);
const encoded = this.encoder.encode(JSON.stringify(data));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoded
);
return JSON.stringify({
iv: Array.from(iv),
data: Array.from(new Uint8Array(encrypted))
});
}
static async decrypt(payload: string, secret: string): Promise<any> {
const { iv, data } = JSON.parse(payload);
const key = await this.generateKey(secret);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(iv) },
key,
new Uint8Array(data)
);
return JSON.parse(this.decoder.decode(decrypted));
}
}
Why This Matters
AES-GCM prevents tampering
PBKDF2 slows brute-force attacks
No external libraries → smaller attack surface
3. Generic IndexedDB Service (Angular Injectable)
This is the core of the system: a generic, reusable IndexedDB key–value store.
Design Highlights
Generic type
<T>Single database, single object store
Promise-based CRUD API
Transparent encryption/decryption
Centralized initialization
indexed-db.service.ts
import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment';
import { CryptoUtil } from './crypto.util';
@Injectable({
providedIn: 'root'
})
export class IndexedDbService<T> {
private readonly dbName = 'APP_DB';
private readonly storeName = 'KEY_VALUE_STORE';
private db!: IDBDatabase;
constructor() {
this.init();
}
private init(): void {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = (event: any) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
};
request.onsuccess = () => {
this.db = request.result;
};
request.onerror = () => {
console.error('IndexedDB initialization failed');
};
}
private getStore(mode: IDBTransactionMode): IDBObjectStore {
const tx = this.db.transaction(this.storeName, mode);
return tx.objectStore(this.storeName);
}
/* ---------------- CRUD OPERATIONS ---------------- */
async set(key: string, value: T): Promise<void> {
const store = this.getStore('readwrite');
const data = environment.production
? await CryptoUtil.encrypt(value, environment.indexedDbEncryptionKey)
: value;
return new Promise((resolve, reject) => {
const req = store.put(data as any, key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async get(key: string): Promise<T | null> {
const store = this.getStore('readonly');
return new Promise((resolve, reject) => {
const req = store.get(key);
req.onsuccess = async () => {
if (!req.result) {
resolve(null);
return;
}
if (environment.production) {
const decrypted = await CryptoUtil.decrypt(
req.result,
environment.indexedDbEncryptionKey
);
resolve(decrypted as T);
} else {
resolve(req.result as T);
}
};
req.onerror = () => reject(req.error);
});
}
async delete(key: string): Promise<void> {
const store = this.getStore('readwrite');
return new Promise((resolve, reject) => {
const req = store.delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async clear(): Promise<void> {
const store = this.getStore('readwrite');
return new Promise((resolve, reject) => {
const req = store.clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
}
4. Usage Example (Feature-Level Abstraction)
You should never inject IndexedDB directly into components. Instead, create domain-specific cache services.
user-cache.service.ts
@Injectable({ providedIn: 'root' })
export class UserCacheService {
constructor(private db: IndexedDbService<any>) {}
saveUser(user: any) {
return this.db.set('USER_PROFILE', user);
}
getUser() {
return this.db.get('USER_PROFILE');
}
clearUser() {
return this.db.delete('USER_PROFILE');
}
}
This keeps:
Storage logic centralized
Domain logic explicit
Refactoring painless
5. Security Considerations (Read This Carefully)
What This Is Good For
| Use Case | Recommended |
| Offline-first apps | ✅ Yes |
| API response caching | ✅ Yes |
| Large structured data | ✅ Yes |
| UI state persistence | ✅ Yes |
What This Is Not For
| Data Type | Recommendation |
| JWT tokens | ❌ No |
| OAuth tokens | ❌ No |
| Refresh tokens | ❌ No |
| Long-term secrets | ❌ No |
🔐 Auth data should use HttpOnly cookies or secure server-side sessions.
Optional Enhancements
If you’re taking this to the next level, consider:
🔄 Encryption key rotation strategy
⏳ TTL / auto-expiry support
🧬 Versioned object stores
📡 RxJS or Signals wrapper
📦 Dexie.js adapter (if allowed)
🏢 Multi-tenant / RBAC-aware key namespaces
Final Thoughts
This pattern strikes a pragmatic balance between:
Security
Performance
Maintainability
Angular best practices
It avoids unnecessary dependencies, aligns with modern browser standards, and scales cleanly in enterprise environments.