Encrypted Autofill in Ionic: AES Credential Storage with Crypto-JS
Fleet operators log into the app from shared tablets in truck cabs and depot offices. Different drivers, different shifts, same device. They don't want to type their credentials every time they open the app - and with tablets shared between people, they shouldn't have to. So the app has a "Remember Me" checkbox that saves credentials between sessions.
My first version stored those credentials in localStorage. It took about ten minutes to build and worked perfectly.
Then I thought about the threat model. These tablets sit in truck cabs, sometimes unlocked, sometimes handed between drivers without a second thought. Anyone with basic developer tools knowledge - and on an Android device that's not exactly hard to access - could open the console and read the stored password in plain text. In a fleet of hundreds of vehicles across South Africa, that's not a theoretical risk. It's a matter of time.
I needed encryption. But I also needed the UX to stay identical - the same "Remember Me" checkbox, the same email pre-fill on the login screen. No extra steps for the user. Same experience, different security posture underneath.
Three services, clear boundaries
The system splits into three pieces with no overlap:
- SecurityService - encryption and decryption primitives, key derivation from device fingerprint
- AutofillService - save, load and migrate credential data via Ionic Storage
- LoginPageComponent - orchestrates the flow, manages the form, calls autofill on init
Keeping them separate matters because the SecurityService has no knowledge of storage, the AutofillService has no knowledge of the login form, and the component orchestrates both without knowing anything about AES or fingerprinting. If I ever need to swap the encryption library or the storage mechanism, each change is contained.
Key derivation - why I couldn't just hardcode a key
The most obvious approach to AES encryption is a hardcoded key. It would work. It would also mean anyone who decompiled the APK could decrypt every user's stored credentials with the same key. That's not much better than plaintext.
The key needs to be different per device. The solution was deriving it from a device fingerprint - a hash of properties that are stable on a given device but different across devices:
import * as CryptoJS from "crypto-js"; @Injectable({ providedIn: "root" }) export class SecurityService { private readonly baseKey = "TruckAssist_2024_SecureKey"; private generateEncryptionKey(): string { const deviceInfo = this.getDeviceFingerprint(); return CryptoJS.SHA256(this.baseKey + deviceInfo).toString(); } private getDeviceFingerprint(): string { const fingerprint = [ navigator.userAgent, navigator.language, screen.width + "x" + screen.height, new Date().getTimezoneOffset().toString(), "ionic-capacitor", ].join("|"); return CryptoJS.SHA256(fingerprint).toString().substring(0, 16); } }
The fingerprint combines user agent, screen resolution, timezone offset and language - hashed to 16 characters, then combined with the base key and hashed again to produce the final encryption key. The consequence is that encrypted autofill data copied from one tablet to another won't decrypt - the fingerprint is different, the key is different, the data is useless.
This is worth being honest about as a trade-off. A fingerprint based on user agent and screen size isn't cryptographically strong - browser updates change the user agent and two identical tablets produce identical fingerprints. For a fleet app on managed devices this provides meaningful protection against casual data extraction. For a banking app you'd use hardware-backed key storage via Capacitor's Keychain/Keystore APIs. The threat model here is someone who finds an unlocked shared tablet and opens dev tools, not a sophisticated attacker with physical device access and forensic tools.
Encrypt and decrypt - and the reason for the double try/catch
The encryption and decryption methods are mostly straightforward AES operations. The interesting part is the error handling:
public encryptData(data: any, customKey?: string): string | null { if (!data) return null; try { const jsonString = JSON.stringify(data); const key = customKey || this.getAutofillEncryptionKey(); return CryptoJS.AES.encrypt(jsonString, key).toString(); } catch (error) { return null; } } public decryptData(encryptedData: string, customKey?: string): any { if (!encryptedData) return null; try { const key = customKey || this.getAutofillEncryptionKey(); const decryptedBytes = CryptoJS.AES.decrypt(encryptedData, key); if (!decryptedBytes || decryptedBytes.sigBytes <= 0) { return null; } let decryptedString: string; try { decryptedString = decryptedBytes.toString(CryptoJS.enc.Utf8); } catch (utf8Error) { return null; } if (!decryptedString) return null; return JSON.parse(decryptedString); } catch (error) { return null; } }
The double try/catch around UTF-8 conversion isn't defensive programming for its own sake. When Crypto-JS decrypts with the wrong key it doesn't throw immediately at the AES step - it produces garbage bytes that only fail when you try to convert them to UTF-8. I found this by testing what happened when a user's user agent changed after a browser update. The outer try/catch didn't catch it. The decryption "succeeded" and then blew up a layer later trying to parse the garbage string as JSON. The inner catch around the UTF-8 conversion is the fix for that specific failure mode.
The acceptable cases where decryption returns null are: a browser update changed the fingerprint (user needs to log in once to re-save), a device swap, or corrupted storage data. All of them result in the same UX - the email field is empty on the login screen, the user logs in normally, and if they tick "Remember Me" the fresh credentials get saved encrypted.
Ionic Storage instead of localStorage
The encrypted data is persisted through @ionic/storage-angular rather than localStorage directly. Ionic Storage picks the best available mechanism per platform - IndexedDB, SQLite, or localStorage as a fallback - and gives a consistent async API across all of them:
@Injectable({ providedIn: "root" }) export class AutofillService { private readonly STORAGE_KEY = "autofillData"; private storageReady = false; constructor( private storage: Storage, private securityService: SecurityService ) { this.init(); } private async init() { await this.storage.create(); this.storageReady = true; } public async saveAutofillData(data: any): Promise<void> { await this.waitForStorageReady(); if (data === null || data === undefined) { await this.clearAutofillData(); return; } const encryptedData = this.securityService.encryptData(data); if (encryptedData) { await this.storage.set(this.STORAGE_KEY, encryptedData); } else { throw new Error("Failed to encrypt autofill data"); } } public async getAutofillData(): Promise<any> { await this.waitForStorageReady(); try { const encryptedData = await this.storage.get(this.STORAGE_KEY); if (!encryptedData) return null; const decryptedData = this.securityService.decryptData(encryptedData); if (decryptedData && typeof decryptedData === "object") { return decryptedData; } else { await this.clearAutofillData(); return null; } } catch (error) { await this.clearAutofillData(); return null; } } }
The waitForStorageReady() detail matters more than it looks. Ionic Storage initialises asynchronously - create() sets up the underlying driver and that takes a moment. The login page calls loadAutofillData() in the constructor, which means it can run before storage is ready. Any read or write that arrives before create() completes fails silently. The polling wait handles it:
private async waitForStorageReady(): Promise<void> { if (this.storageReady) return; return new Promise<void>((resolve) => { const intervalId = setInterval(() => { if (this.storageReady) { clearInterval(intervalId); resolve(); } }, 100); }); }
Polls every 100ms until storage is ready. I discovered the need for this during early testing when autofill worked on every second app launch. The first launch the storage hadn't initialised in time, the read returned nothing, and the email field was empty. The second launch the storage was already initialised from the previous session and it worked. The pattern was baffling until I understood the async initialisation timing.
The login page - where it all connects
The login page calls loadAutofillData() in the constructor - before the view renders - so the email field is pre-populated by the time the user sees the screen:
constructor( private autofillService: AutofillService, ) { this.loginForm = new FormGroup({ email: new FormControl("", Validators.compose([ Validators.required, Validators.pattern("^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+$"), ])), password: new FormControl("", Validators.compose([ Validators.minLength(5), Validators.required, ])), rememberMe: new FormControl(false), }); this.loadAutofillData(); } async loadAutofillData() { try { let toCheck = await this.autofillService.getAutofillData(); if (toCheck !== null && toCheck !== undefined) { this.autofillData = toCheck; if (this.autofillData.email) { this.loginForm.get("email")?.setValue(this.autofillData.email); } if (this.autofillData.rememberMe) { this.loginForm.get("rememberMe")?.setValue(this.autofillData.rememberMe); } } } catch (error) { await this.autofillService.clearAutofillData(); this.autofillData = { email: "", password: "", rememberMe: false }; } }
The password is not pre-filled into the visible input field. The encrypted data includes the password for silent auto-submission when "Remember Me" is ticked, but we don't render it in the DOM where it could be shoulder-surfed by someone walking past an unlocked shared tablet. Email pre-fills for convenience, password stays invisible.
The isLoggingIn setter disables all form inputs during a request in-flight:
set isLoggingIn(value: boolean) { this._isLoggingIn = value; if (this.loginForm) { if (value) { this.loginForm.get("email")?.disable(); this.loginForm.get("password")?.disable(); this.loginForm.get("rememberMe")?.disable(); } else { this.loginForm.get("email")?.enable(); this.loginForm.get("password")?.enable(); this.loginForm.get("rememberMe")?.enable(); } } }
This prevents a specific bug I hit during testing - editing the email field while the login request was in-flight caused the saved autofill data to not match what was actually submitted, corrupting the saved credentials. Disabling the inputs during the request makes the state coherent.
The migration path - existing users never noticed
When I shipped the encrypted storage, every existing user had their credentials stored as plaintext from the old version. I couldn't just switch storage keys - that would silently clear their saved logins and they'd have to re-enter credentials and tick "Remember Me" again. On shared fleet tablets with multiple operators this would have generated a wave of confused calls.
The migration runs transparently on first load after the update:
public async migrateToEncryptedStorage(): Promise<boolean> { try { await this.waitForStorageReady(); const existingData = await this.storage.get(this.STORAGE_KEY); if (!existingData) return true; try { const parsed = JSON.parse(existingData); if (parsed && typeof parsed === "object" && !parsed.encrypted) { await this.saveAutofillData(parsed); return true; } } catch { const decrypted = this.securityService.decryptData(existingData); if (decrypted) { return true; // Already encrypted } else { await this.clearAutofillData(); return false; } } return true; } catch (error) { return false; } }
If the stored data parses as JSON directly, it's plaintext from the old format - encrypt it and re-save. If JSON parsing fails, try to decrypt it - if that succeeds, it's already encrypted and nothing needs to happen. If neither works, the data is corrupted and gets cleared. Every case is handled and every case is silent. Operators opened the app after the update and their email was still pre-filled. Nobody called.
Zero support tickets. That's the outcome I wanted and the one the migration path delivered.
This article is part of a series on building a fleet telematics platform.