The Day I Learned Hashing Isn't Always Security
Admin User
Author
I was six months into building a fintech product when I made a decision that should have broken everything. I was storing user bank account numbers, and my instinct—the same instinct I'd relied on for years—told me to hash them. Hash everything sensitive. It's secure. It's irreversible. It felt right.
Then a product manager asked: "Can we show users their last four digits for confirmation?" And suddenly my beautiful one-way hashing scheme made no sense. You can't recover "last four digits" from a SHA-256 hash. You just can't. That's the entire point of hashing. I spent that evening reading through my own code thinking about how close I came to shipping something that looked secure but was actually broken for the feature it was supposed to protect.
This is the gap that the original article articulates perfectly, and it's a gap I see constantly in production code. We learn security rules as absolutes—"never store plaintext sensitive data"—without understanding the actual problem each rule solves. Hashing and encryption are not interchangeable. The consequences of picking the wrong one are expensive.
Hashing vs Encryption: The Problem They Actually Solve
When I hash a password, I'm answering a specific question: "Does this input match what I stored before?" I don't need the original value back. I never will. The entire security model depends on that irreversibility.
But KYC verification, like my bank account confirmation use case, asks a different question: "What was the original value?" An admin reviewing a government ID number needs to see the actual number. They need to verify it matches the document the user provided. A hash gives you nothing here. It's the wrong tool, regardless of how secure hashing is as a cryptographic operation.
This distinction matters because I've seen developers conflate "strong hashing algorithm" with "secure storage." They're not the same. AES-256 with GCM authenticated encryption actually protects the data and allows recovery. That's what you need when someone with legitimate authority needs to access the plaintext.
The Architecture That Makes Sense
The approach in the original article is clean: encrypt sensitive values at rest using AES-256-GCM, store the ciphertext, and separate the encryption key from the encrypted data entirely. The key lives in environment variables or a secrets manager—never in the database.
Here's what I appreciate about this: it actually solves the problem. An admin can decrypt and view the document number when needed. The encryption still protects against database breaches (encrypted data is useless without the key). And the GCM mode adds authentication—if someone tampers with the ciphertext, decryption fails instead of silently returning garbage.
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
import base64
def encrypt_value(plaintext: str, key: bytes) -> str:
cipher = AESGCM(key)
nonce = os.urandom(12) # Unique per encryption
ciphertext = cipher.encrypt(nonce, plaintext.encode(), None)
# Store nonce with ciphertext; you need both for decryption
return base64.b64encode(nonce + ciphertext).decode()
def decrypt_value(encrypted: str, key: bytes) -> str:
raw = base64.b64decode(encrypted)
nonce, ciphertext = raw[:12], raw[12:]
cipher = AESGCM(key)
return cipher.decrypt(nonce, ciphertext, None).decode()
The nonce is critical—it's random for every encryption, which prevents patterns in the ciphertext even if you encrypt the same value multiple times. That's good security fundamentals.
What I'd Add: Audit Logging From Day One
The original article mentions logging every decrypt as an audit event. I want to emphasize how important this is, because it's often an afterthought.
Encryption protects data from unauthorized people. Audit logging protects data from authorized people. An admin with access to the decryption key is a legitimate threat vector—either through malice, carelessness, or a compromised account. If you're decrypting government ID numbers or financial data, every single decryption should be logged and auditable.
I've seen teams skip this because it feels like overhead. It's not. It's the second layer of security you need after encryption.
My Remaining Questions
What I'm still thinking about: how do you handle key rotation without decrypting and re-encrypting all your data? And more practically—how do you keep the encryption key secure in a microservices environment where multiple services need to decrypt the same values?
The article doesn't address these, which is fine—it's scoped deliberately. But in production, these are the problems that keep me up. Key management at scale is harder than the encryption itself.
What I'm Taking Away
The lesson I'm holding onto: security rules exist because they solve specific problems. Before you apply a rule, understand the problem. Hashing solves "verify this matches." Encryption solves "show this to authorized people but hide it from everyone else." If you apply hashing when you need encryption, you've failed silently. The code is secure, but the feature is broken.
Source: This post was inspired by "I Almost Hashed a Document Number That Needed to Be Read Again" by Dev.to. Read the original article