Local Security
PIN protection, biometric authentication, and auto-lock for apps with client-side data.
Local Security
Prerequisites: Auth Store.
For apps where all data lives on the client (IndexedDB), local security protects data from physical access.
This pattern is not needed if your app always requires a backend connection — auth is handled server-side.
PIN Lock
PIN Store
Uses PBKDF2 with a random salt and 600,000 iterations — the recommended minimum by OWASP. PINs are short (4-6 digits), so iteration count and salt are critical.
const PIN_STORE = "lock-pin";
function openLockDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("swoff-lock", 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(PIN_STORE)) {
db.createObjectStore(PIN_STORE, { keyPath: "key" });
}
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
}
async function hashPin(pin, salt) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(pin),
"PBKDF2",
false,
["deriveBits"],
);
const hash = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations: 600000,
hash: "SHA-256",
},
keyMaterial,
256,
);
return new Uint8Array(hash);
}
export async function setPin(pin) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const hash = await hashPin(pin, salt);
const db = await openLockDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(PIN_STORE, "readwrite");
const store = tx.objectStore(PIN_STORE);
store.put({
key: "pinData",
value: { hash: Array.from(hash), salt: Array.from(salt) },
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function verifyPin(pin) {
const db = await openLockDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(PIN_STORE, "readonly");
const store = tx.objectStore(PIN_STORE);
const request = store.get("pinData");
request.onsuccess = async () => {
const stored = request.result?.value;
if (!stored) return resolve(true); // No PIN set
const salt = new Uint8Array(stored.salt);
const hash = await hashPin(pin, salt);
const match = stored.hash.every((b, i) => b === hash[i]);
resolve(match);
};
request.onerror = () => reject(request.error);
});
}Biometric / WebAuthn
/**
* Check if biometric auth is available on this device.
*/
export function isBiometricAvailable() {
return (
window.PublicKeyCredential &&
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?.()
);
}
/**
* Authenticate using device biometrics (fingerprint, face, etc.).
* Returns true if the user verified successfully.
*/
export async function authenticateWithBiometric() {
if (!isBiometricAvailable()) return false;
try {
// WebAuthn assertion (simplified — your server handles challenge/response)
const credential = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(32), // In production, get from your server
rpId: window.location.hostname,
userVerification: "required",
timeout: 60000,
},
});
return credential !== null;
} catch {
return false;
}
}Auto-Lock Timer
Lock the app after a period of inactivity. User sets the timeout in settings.
let lockTimer = null;
let lockTimeout = 5 * 60 * 1000; // Default: 5 minutes
export function setLockTimeout(ms) {
lockTimeout = ms;
}
export function startLockTimer() {
stopLockTimer();
if (lockTimeout <= 0) return; // Disabled
lockTimer = setTimeout(() => {
window.dispatchEvent(new CustomEvent("sw-lock-required"));
}, lockTimeout);
}
export function stopLockTimer() {
if (lockTimer) clearTimeout(lockTimer);
lockTimer = null;
}
export function resetLockTimer() {
startLockTimer(); // Restart the countdown
}
// Reset on user activity
document.addEventListener("click", resetLockTimer);
document.addEventListener("keydown", resetLockTimer);
document.addEventListener("touchstart", resetLockTimer);Lock on App Visibility Change
Lock when the app goes to background and returns after a threshold.
// In your app initialization
const LOCK_THRESHOLD = 30000; // 30 seconds
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
// App went to background — remember time
sessionStorage.setItem("lock-background-timestamp", Date.now());
} else {
// App came to foreground
const bgTime = sessionStorage.getItem("lock-background-timestamp");
if (bgTime && Date.now() - Number(bgTime) > LOCK_THRESHOLD) {
window.dispatchEvent(new CustomEvent("sw-lock-required"));
}
sessionStorage.removeItem("lock-background-timestamp");
}
});Lock Screen Component
When your app receives sw-lock-required, show a lock screen:
window.addEventListener("sw-lock-required", () => {
showLockScreen();
});
async function showLockScreen() {
// 1. Render PIN input / biometric prompt
// 2. On submit:
const valid = await verifyPin(enteredPin);
if (valid) {
hideLockScreen();
resetLockTimer();
} else {
showError("Wrong PIN");
}
}Events
| Event | When |
|---|---|
sw-lock-required | Auto-lock timer expired, or app resumed from background |
Integration Checklist
- PIN storage with PBKDF2 + random salt (600,000 iterations minimum)
- Biometric availability check + authentication
- Auto-lock timer with configurable timeout
- Visibility change detection
- Lock screen UI component
- PIN setup in app settings
Next Steps
- Offline Auth State — four-state detection
- Auth Store — token storage
Swoff