Implementing personal data encryption on a website
Personal data encryption in the database is a mandatory element of protection when handling sensitive information. Even if the database is compromised, the attacker gets encrypted data useless without keys.
What and how to encrypt
Data requiring encryption:
- INN, SNILS, passport series/number
- Medical data, diagnoses
- Financial data, card numbers
- Biometrics
Data that should only be hashed (irreversible):
- Passwords → bcrypt, Argon2id
- Secret tokens, API keys
Data requiring searchability:
- Requires deterministic encryption or tokenization
Encryption algorithms
AES-256-GCM — symmetric encryption with authentication. Standard for data at rest encryption.
RSA-OAEP — asymmetric. Used for encrypting symmetric keys.
ChaCha20-Poly1305 — AES alternative on platforms without hardware AES acceleration.
Application-level encryption (Laravel)
class EncryptedCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): ?string
{
if (is_null($value)) return null;
try {
return Crypt::decryptString($value);
} catch (DecryptException) {
return null;
}
}
public function set($model, string $key, $value, array $attributes): ?string
{
if (is_null($value)) return null;
return Crypt::encryptString($value);
}
}
class Patient extends Model
{
protected $casts = [
'passport_number' => EncryptedCast::class,
'medical_notes' => EncryptedCast::class,
'snils' => EncryptedCast::class,
];
}
$patient->passport_number = '4510 123456';
$decrypted = $patient->passport_number;
Key management
HashiCorp Vault:
$vault = new Vault([
'address' => 'https://vault.internal:8200',
'token' => env('VAULT_TOKEN'),
]);
$keyData = $vault->read('secret/data/app-encryption-key');
$encryptionKey = $keyData['data']['key'];
AWS KMS:
$kms = new KmsClient(['region' => 'eu-west-1']);
$result = $kms->encrypt([
'KeyId' => 'arn:aws:kms:eu-west-1:123456:key/abc-123',
'Plaintext' => $sensitiveData,
]);
$encryptedData = base64_encode($result['CiphertextBlob']);
Envelope Encryption — best practice: data encrypted with Data Encryption Key (DEK), DEK encrypted with Key Encryption Key (KEK), KEK stored in KMS/Vault.
Deterministic encryption for search
Regular AES-GCM generates different ciphertext for same value. Searching encrypted field is impossible. Solutions:
Variant 1: Hash for search + encryption for storage:
class PersonalDataRepository
{
public function findByPassport(string $passport): ?Patient
{
$hash = hash_hmac('sha256', $passport, config('app.search_key'));
return Patient::where('passport_hash', $hash)->first();
}
public function store(string $passport): void
{
Patient::create([
'passport_data' => Crypt::encryptString($passport),
'passport_hash' => hash_hmac('sha256', $passport, config('app.search_key')),
]);
}
}
Variant 2: PostgreSQL pgcrypto:
INSERT INTO patients (passport)
VALUES (pgp_sym_encrypt('4510 123456', current_setting('app.encryption_key')));
SELECT pgp_sym_decrypt(passport::bytea, current_setting('app.encryption_key'))
FROM patients WHERE id = 1;
Key rotation
class RotateEncryptionKeyCommand extends Command
{
public function handle(): void
{
$oldKey = config('app.old_encryption_key');
$newKey = config('app.key');
Patient::chunk(100, function ($patients) use ($oldKey, $newKey) {
foreach ($patients as $patient) {
$decrypted = Crypt::decryptString($patient->getRawOriginal('passport_number'));
$patient->updateQuietly([
'passport_number' => Crypt::encryptString($decrypted),
]);
}
});
}
}
File encryption
class EncryptedFileStorage
{
public function store(UploadedFile $file): string
{
$content = file_get_contents($file->getPathname());
$encrypted = Crypt::encrypt($content);
$path = 'encrypted/' . Str::uuid() . '.enc';
Storage::put($path, $encrypted);
return $path;
}
public function retrieve(string $path): string
{
$encrypted = Storage::get($path);
return Crypt::decrypt($encrypted);
}
}
Implementation Timeline
- Encryption on model level (Cast) for main fields: 2–3 days
- Vault/KMS integration + envelope encryption: 5–7 days
- Deterministic encryption + search: +3 days
- Key rotation + access audit: +2–3 days







