DEV Community

Muhammad Hammad
Muhammad Hammad

Posted on

Architectural Breakdown: Encryption, Explained Properly: AES vs ChaCha20 vs Argon2id (and How to Act

![Architecture Diagram](https://image.pollinations.ai/prompt/high+performance+cloud+systems+Encryption%2C+Explained+Properly+round+2?width=800&height=400&nologo=true)

# Encryption, Explained Properly: AES vs ChaCha20 vs Argon2id (and How to Actually Combine Them)

Let's be honest. Most developers treat encryption like a checkbox. Install a crypto library, call one function, ship it, pat yourself on the back. Meanwhile, credentials are leaking in plaintext across three microservices because nobody bothered to understand what they're actually protecting.

This isn't a tutorial. It's damage control.

## Stop Treating All Crypto as Interchangeable

You've got three different tools here. They do fundamentally different jobs. Mixing them up is how you end up with someone's password visible in your database logs.

### AES, The Workhorse (With Caveats)

AES is a block cipher. It encrypts data in 128-bit chunks using symmetric keys. It is fast, well-studied, and hardware-accelerated on essentially every chip made after 2010. When people say "encrypt this payload," they usually mean AES-GCM or AES-SIV.

The problem? It is not a password scheme. You do not feed it raw passwords and expect magic. AES operates on fixed-size blocks with a key. Feed it garbage, get garbage out. It has zero resistance to brute force. A key is a key, period.

Use AES when you need to encrypt large volumes of data at rest or in transit. Not passwords. Not secrets derived from human memory. Data.

### ChaCha20, The Speed Demon for Software

Born from the same mind that created ChaCha (Dan Bernstein, because apparently one genius wasn't enough), ChaCha20 is a stream cipher that deliberately avoids table-based attacks. While AES relies on S-box lookups that can leak via cache timing, ChaCha20 works with simple arithmetic: additions, XORs, rotations.

In pure software implementations, ChaCha20 often outperforms AES by two to three times because it does not need those costly lookup tables. On ARM without AES-NI? ChaCha20 does not even ask for permission. It just runs.

Chrome uses it. WireGuard uses it. The Cloudflare engineers are not doing this for Instagram points.

But here is what nobody tells you: ChaCha20 and AES-GCM are interchangeable for bulk encryption. Both are authenticated. Both give you confidentiality plus integrity. Pick based on your platform, not your feelings.

### Argon2id, Because Passwords Are Not Data

Now we are getting somewhere useful.

Argon2id is the only password hashing algorithm that will not make you look like an idiot in 2024. It is the winner of the 2015 Password Hashing Competition. It combines two approaches:

- **Argon2i**: resistant to side-channel attacks (good for situations where execution time varies)
- **Argon2d**: faster but vulnerable to timing attacks (good for GPU-resistant scenarios)

**Argon2id** uses both. Time-based branching for the first pass (d), memory-intensive operations for the second (i). The result? Brutally expensive to brute force, resistant to GPU and ASIC attacks, and configurable on three axes: memory, iterations, and parallelism.

Here is what most implementations get wrong: they use default parameters. The spec calls for a minimum of 2x the memory in kilobytes as the password length, with at least three iterations. In practice, you want parameters that take approximately 0.5 seconds on your target hardware. That is not pessimism. That is the line between "annoying for attackers" and "impossible for attackers."

If your hashing takes 50 milliseconds, you are being lazy. If it takes 5 seconds, you are being dramatic. Somewhere in between is production.

## How to Actually Combine Them

This is where 90 percent of tutorials fail. They show you AES encryption. Then they show you Argon2 hashing. Then they shrug and say "and that is encryption."

No. That is two different problems solved by two different tools. Here is what a real pipeline looks like:

Enter fullscreen mode Exit fullscreen mode

User password → Argon2id (hash for storage)
User data → AES-GCM (encrypt with derived key)
Key wrapping → ChaCha20-Poly1305 (encrypt the AES key itself)


### Step 1: Password Hashing (Argon2id)

When a user registers or logs in, you hash their password with Argon2id. This is your verification mechanism. Never store plaintext. Never store reversible hashes. Never, ever, use MD5, SHA-256, or bcrypt unless you are maintaining legacy systems you cannot touch.

Enter fullscreen mode Exit fullscreen mode


python

Production-grade Argon2id configuration

time_cost=3: three passes through memory (slows GPU attacks)

memory_cost=65536: 64 MB of RAM per hash (brute-force deterrent)

parallelism=4: leverage multi-core CPUs without overloading

hash = argon2id.hash(
password,
time_cost=3,
memory_cost=65536,
parallelism=4,
hash_len=32, # 256-bit output for full security margin
salt=secrets.token_bytes(16) # unique salt per password
)


### Step 2: Data Encryption (AES-GCM)

When storing sensitive payloads (PII, payment details, anything that makes your legal team sweat), encrypt with AES-256-GCM. The key should be derived from your Argon2id output or a separate KDF, not the password itself.

AES-GCM gives you authenticated encryption. The ciphertext includes a tag. If anyone tampers with it, decryption fails. No exceptions.

Enter fullscreen mode Exit fullscreen mode


python
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os

Derive an encryption key from the hashed password using HKDF

This separates identity (password) from the actual encryption material

def derive_encryption_key(password_hash: bytes) -> bytes:
"""HKDF-SHA256: Extract-then-expand pattern for key derivation."""
salt = b"enc-key-salt-v1" # application-wide unique salt
return HKDF(
algorithm=hashes.SHA256(),
length=32, # 256-bit AES key
salt=salt,
info=b"encryption-key", # context-binding info
ikm=password_hash # input keying material from Argon2id
).derive()

Generate a fresh AES key for each data batch

key = derive_encryption_key(stored_password_hash)
aesgcm = AESGCM(key)

Nonce MUST be unique per key. A counter or random approach works.

nonce = os.urandom(12) # 96-bit nonce (standard for AES-GCM)
aad = b"user-data-context-v1" # associated data (authenticated but not encrypted)
ciphertext = aesgcm.encrypt(nonce, sensitive_payload.encode(), aad)

Store: nonce + ciphertext + tag (all together)

stored_record = nonce + ciphertext


### Step 3: Key Protection (ChaCha20-Poly1305)

Your AES key needs protection too. Encrypt it with ChaCha20-Poly1305 using a key derived from a separate source. This gives you defense in depth. Even if one layer is compromised, the data remains protected.

Enter fullscreen mode Exit fullscreen mode


python
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes

def wrap_aes_key(aes_key: bytes, master_secret: bytes) -> dict:
"""
Wrap the AES encryption key with ChaCha20-Poly1305.
Returns a dict with nonce and wrapped_key for storage.
"""
# Derive a separate wrapping key from the master secret
wrap_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"key-wrapping-key",
ikm=master_secret
).derive()

chacha = ChaCha20Poly1305(wrap_key)
nonce = os.urandom(12)          # 96-bit nonce for ChaCha20
aad = b"wrapped-aes-key-v1"     # context-binding associated data
wrapped = chacha.encrypt(nonce, aes_key, aad)

return {"nonce": nonce, "wrapped_key": wrapped}
Enter fullscreen mode Exit fullscreen mode

## The Production Reality

I have reviewed enough "encrypted" databases to know that most of them are not. They are using hardcoded keys, or salts that change per request (rendering them useless), or the "encryption" is just Base64 with a custom alphabet.

What actually ships in production treats crypto as a system, not a function call:

1. **Keys are derived, not stored.** Use HKDF or similar KDFs to generate encryption keys from your hashed passwords.
2. **Nonce management matters.** AES-GCM and ChaCha20 both require unique nonces per key. Reusing a nonce is catastrophic. Use a counter or randomized approach.
3. **Algorithm selection is contextual.** Mobile-first apps? Lean ChaCha20. Data center with AES-NI? AES-GCM is fine. Password storage? Argon2id, no debate.
4. **Regular audits.** Rotate keys. Update parameters. Re-hash existing passwords when you bump Argon2id cost factors.

## What You Are Probably Doing Wrong Right Now

Let me guess:

- You are using `Cryptodome` (not `cryptography`) or some wrapper that abstracts away the dangerous parts
- Your "salt" is generated once at startup and never changes (making rainbow table attacks trivial)
- You are encrypting the database column instead of the application layer (the classic "I will just encrypt the blob" move that does not actually protect anything)
- You are using a single encryption key for everything (if one key leaks, everything leaks)
- You have not considered that your cloud provider's KMS might be the bottleneck, not your code

## The Bottom Line

Encryption is not a feature. It is a discipline.

AES for bulk data. ChaCha20 for software-only environments. Argon2id for passwords. Combine them with proper key derivation and you have a system that does not make headlines for the wrong reasons.

Stop choosing crypto based on what is easiest to import. Choose based on what is correct for the problem.

Your users' data will thank you. Or at least, your lawyers will.

---

**Open Loop:** When was the last time you audited your nonce generation strategy under concurrent load, and did you actually test what happens when two requests collide on the same key-nonce pair?
Enter fullscreen mode Exit fullscreen mode

Top comments (0)