DEV Community

Cover image for 8 Essential Python Cryptography Techniques Every Developer Must Know for Bulletproof Security
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

8 Essential Python Cryptography Techniques Every Developer Must Know for Bulletproof Security

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Let's talk about keeping secrets. In our digital world, that's what cryptography does. It's not just about spies; it's about protecting your messages, your passwords, and your data every single day. I use Python for this because it turns complex math into understandable tools. Here are eight fundamental methods I use to build security into applications, explained as simply as I can, with code you can run yourself.

First, let's look at keeping something hidden with one key. Imagine you and a friend have a single, special key to a locked box. You put a message inside, lock it, and send it. They use their copy of the same key to open it. This is symmetric encryption.

In Python, a tool called Fernet makes this straightforward and safe. It not only locks the data but also seals it, so you know if someone tried to peek inside during delivery.

from cryptography.fernet import Fernet

# This makes a new, random key. Guard this key!
key = Fernet.generate_key()
cipher = Fernet(key)

my_secret = b"The secret recipe is one cup of sugar."
locked_box = cipher.encrypt(my_secret)
print(f"Encrypted data looks like gibberish: {locked_box[:50]}...")

# To read it, use the same key.
original_message = cipher.decrypt(locked_box)
print(f"Back to normal: {original_message.decode()}")

# What if someone messes with the box in transit?
tampered_box = locked_box[:-10] + b'FAKEDATA'
try:
    cipher.decrypt(tampered_box)
    print("This won't print.")
except:
    print("The seal broke! The data was tampered with.")
Enter fullscreen mode Exit fullscreen mode

The beauty here is the built-in check. If the encrypted data is altered, even by a single byte, the decryption will fail. You don't just get wrong data; you get a clear error. This is called authenticated encryption.

Now, what if you've never met your friend to share a key? You need a different method. This is where two keys come in: a public key and a private key. You can think of the public key like an open lock. Anyone can snap it shut on a message. But only the holder of the unique private key can open that lock. This is asymmetric encryption, and RSA is a common way to do it.

from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes

# Someone (let's call her Alice) makes a key pair.
alice_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
alice_public_key = alice_private_key.public_key()

# I want to send Alice a secret. I use her public lock.
message_for_alice = b"Meeting at the cafe on 5th street."
ciphertext = alice_public_key.encrypt(
    message_for_alice,
    padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None
    )
)

# Only Alice can open it with her private key.
decrypted_by_alice = alice_private_key.decrypt(
    ciphertext,
    padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
)
print(f"Alice got the message: {decrypted_by_alice.decode()}")
Enter fullscreen mode Exit fullscreen mode

Notice the padding.OAEP. Using encryption correctly involves more than just the math. This padding scheme is crucial for security. Using the "raw" RSA math without it is a common and dangerous mistake.

Sometimes, I don't need to hide a message, but I need to prove I wrote it and that it hasn't been changed. This is a digital signature. I use my private key to sign a document. Anyone with my public key can check that signature. If the document changed by even a comma, the signature won't match.

from cryptography.hazmat.primitives.asymmetric import ec

# A different type of key, good for signatures, is based on Elliptic Curves.
signing_key = ec.generate_private_key(ec.SECP256R1())
verification_key = signing_key.public_key()

contract = b"I, the undersigned, agree to pay $100."

# I sign it with my private key.
signature = signing_key.sign(contract, ec.ECDSA(hashes.SHA256()))

# You can verify it with my public key.
try:
    verification_key.verify(signature, contract, ec.ECDSA(hashes.SHA256()))
    print("The signature is valid. The document is authentic.")
except Exception:
    print("Warning! This signature is invalid. Do not trust this document.")

# Let's see what happens if a scammer changes the amount.
fraudulent_contract = contract.replace(b"$100", b"$1000")
try:
    verification_key.verify(signature, fraudulent_contract, ec.ECDSA(hashes.SHA256()))
    print("This won't print.")
except Exception:
    print("The fraud was caught! The signature doesn't match the altered text.")
Enter fullscreen mode Exit fullscreen mode

This is how software updates are verified. The company signs the update file with their private key. Your computer checks the signature with their public key before installing. If a hacker provides a malicious file, the signature check fails.

Let's talk about passwords. A critical rule is: never, ever store passwords in plain text. If your database is stolen, all user accounts are compromised. Instead, you store a "hash" of the password. A hash is a one-way street. You can turn a password into a hash, but you can't turn the hash back into the password. When a user logs in, you hash the password they type and compare it to the stored hash.

But simple hashing isn't enough. Hackers use "rainbow tables," which are precomputed hashes for common passwords. To fight this, we add "salt." A salt is random data unique to each user, mixed with their password before hashing. We also use slow hash functions on purpose, to make brute-force attacks impractical.

import bcrypt
import time

# When a user creates an account:
password = "MyStr0ng!P@ss".encode()  # From a registration form

# bcrypt handles the salt generation and hashing internally.
hashed_password = bcrypt.hashpw(password, bcrypt.gensalt(rounds=14))
print(f"Stored in database: {hashed_password.decode()}")

# Later, during login:
login_attempt = "MyStr0ng!P@ss".encode()  # What the user just typed
wrong_attempt = "mystrongpass".encode()

# bcrypt checks the attempt against the stored hash.
if bcrypt.checkpw(login_attempt, hashed_password):
    print("Login successful!")
else:
    print("Wrong password.")

if bcrypt.checkpw(wrong_attempt, hashed_password):
    print("This won't print.")
else:
    print("Wrong password correctly rejected.")

# Let's feel the slowness. This is a good thing.
start = time.time()
test_hash = bcrypt.hashpw(b"test", bcrypt.gensalt(rounds=14))
elapsed = time.time() - start
print(f"Hashing one password took {elapsed:.3f} seconds. An attacker must spend this much time per guess.")
Enter fullscreen mode Exit fullscreen mode

The rounds parameter controls the work factor. You can increase it over time as computers get faster. This slowness is a feature, not a bug. It stops attackers who try billions of passwords per second.

Now, back to two people who've never met. How do they agree on a secret key over a public, watched channel? The Diffie-Hellman key exchange is like a magic trick. Two people can publicly exchange some numbers, do some private math with them, and end up with the same secret number. An eavesdropper sees the public numbers but can't figure out the final secret.

from cryptography.hazmat.primitives.asymmetric import dh

# First, agree on some public parameters. This can be done once and reused.
parameters = dh.generate_parameters(generator=2, key_size=2048)

# Person A (Alice) makes her own key pair.
alice_priv = parameters.generate_private_key()
alice_pub = alice_priv.public_key()

# Person B (Bob) makes his own key pair.
bob_priv = parameters.generate_private_key()
bob_pub = bob_priv.public_key()

# They exchange public keys (alice_pub and bob_pub) openly.

# The magic happens here. They each combine the other's public key with their own private key.
alice_shared_secret = alice_priv.exchange(bob_pub)
bob_shared_secret = bob_priv.exchange(alice_pub)

print(f"Alice's computed secret: {alice_shared_secret.hex()[:20]}...")
print(f"Bob's computed secret:   {bob_shared_secret.hex()[:20]}...")
print(f"Are they identical? {alice_shared_secret == bob_shared_secret}")
Enter fullscreen mode Exit fullscreen mode

This shared secret isn't used directly as a key. It's used as input to a key derivation function to create strong, usable encryption keys. This is the foundation for protocols like TLS, which secures your connection to websites.

Let's combine ideas: encryption and integrity. AES in GCM mode does this in one step. It's like the Fernet example earlier, but gives you more control. You can also bind "associated data" to the encryption—data that isn't secret but must be checked for integrity, like a packet header or a transaction ID.

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import secrets

# Generate a random 256-bit key.
key = AESGCM.generate_key(bit_length=256)

# The data we want to keep secret.
plaintext = b"Credit Card: 4111-1111-1111-1111"
# The data we need to protect from tampering.
associated_data = b"TransactionID: TXN78901; Amount: $49.99"

# To encrypt, we need a "nonce" (Number used ONCE). It must be unique for each encryption with the same key.
nonce = secrets.token_bytes(12)  # 12 bytes is standard for GCM
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data)

# We send the nonce and ciphertext together.
package_to_send = nonce + ciphertext
print(f"Sent package is {len(package_to_send)} bytes.")

# On the receiving end:
received_nonce = package_to_send[:12]
received_ciphertext = package_to_send[12:]

try:
    aesgcm = AESGCM(key)
    # This decrypts AND verifies the associated_data.
    decrypted = aesgcm.decrypt(received_nonce, received_ciphertext, associated_data)
    print(f"Transaction OK. Decrypted data: {decrypted}")
except Exception:
    print("ALERT! The message authentication failed. The data or its context was tampered with.")

# What if a hacker changes the amount in the associated data?
hacker_data = b"TransactionID: TXN78901; Amount: $4999.99"
try:
    aesgcm.decrypt(received_nonce, received_ciphertext, hacker_data)
    print("This won't print.")
except Exception:
    print("Caught! Tampered amount rejected.")
Enter fullscreen mode Exit fullscreen mode

This is incredibly useful. It ensures not only that the credit card number is secret, but also that the transaction amount I'm charging it for hasn't been altered from "$49.99" to "$4999.99."

Where do we get all these random keys and nonces? Not from Python's basic random module. For security, we need randomness that is truly unpredictable. Python's secrets module is built for this.

import secrets
import random

print("Insecure 'random' from the random module (predictable in some contexts):")
for i in range(3):
    print(f"  {random.randrange(1, 10000)}")

print("\nSecure random from the secrets module (unpredictable):")
for i in range(3):
    print(f"  {secrets.randbelow(10000)}")

# Generating tokens and keys:
api_key = secrets.token_urlsafe(32)  # 32 bytes, encoded in URL-safe text
session_token = secrets.token_hex(16)  # 16 bytes, in hexadecimal
one_time_code = ''.join(secrets.choice('0123456789') for i in range(6))

print(f"\nGenerated API Key: {api_key}")
print(f"Generated Session Token: {session_token}")
print(f"Generated 6-digit OTP: {one_time_code}")

# A crucial technique: constant-time comparison.
# Checking two strings/bytes the normal way `a == b` stops early on the first mismatch.
# A clever attacker can time these checks to slowly guess the secret.
# We must compare in a way that always takes the same amount of time.

def dangerous_compare(a, b):
    # DON'T DO THIS FOR SECRETS.
    return a == b

def safe_compare(a, b):
    # Use the secrets module's helper.
    return secrets.compare_digest(a, b)

# Or, from the cryptography library:
from cryptography.hazmat.primitives import constant_time

def also_safe_compare(a, b):
    return constant_time.bytes_eq(a, b)

real_secret = b"SuperSecretAPIKey2024"
user_guess = b"SuperSecretAPIKey2024"
wrong_guess = b"SuperSecretAPIKey2025"

print(f"\nDangerous compare (correct): {dangerous_compare(user_guess, real_secret)}")
print(f"Dangerous compare (wrong):    {dangerous_compare(wrong_guess, real_secret)}")
print(f"Safe compare (correct):       {safe_compare(user_guess, real_secret)}")
print(f"Safe compare (wrong):         {safe_compare(wrong_guess, real_secret)}")
Enter fullscreen mode Exit fullscreen mode

Always use secrets for keys, tokens, and passwords. Use compare_digest or constant_time.bytes_eq for verifying them.

Finally, how do we know a public key really belongs to the website or person it claims to? We use certificates. A certificate is a document that says, "I, a trusted Certificate Authority (CA), verify that this public key belongs to example.com." It's signed by the CA's private key, so we can check it with the CA's widely-known public key.

from cryptography import x509
from cryptography.x509.oid import NameOID
from datetime import datetime, timedelta, timezone

# Let's simulate creating a simple Certificate Authority (CA).
ca_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)

# The CA gives itself a certificate.
subject = issuer = x509.Name([
    x509.NameAttribute(NameOID.ORGANIZATION_NAME, "My Test CA"),
    x509.NameAttribute(NameOID.COMMON_NAME, "my-test-ca.com"),
])
ca_cert = x509.CertificateBuilder().subject_name(
    subject
).issuer_name(
    issuer
).public_key(
    ca_private_key.public_key()
).serial_number(
    x509.random_serial_number()
).not_valid_before(
    datetime.now(timezone.utc)
).not_valid_after(
    datetime.now(timezone.utc) + timedelta(days=3650)  # 10 years
).add_extension(
    x509.BasicConstraints(ca=True, path_length=0), critical=True  # This is a CA cert
).sign(ca_private_key, hashes.SHA256())

print(f"CA Certificate Subject: {ca_cert.subject.rfc4514_string()}")
print(f"CA Certificate is valid until: {ca_cert.not_valid_after}")

# Now, a server needs a certificate.
server_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)

# It creates a Certificate Signing Request (CSR).
csr = x509.CertificateSigningRequestBuilder().subject_name(
    x509.Name([
        x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
        x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"),
        x509.NameAttribute(NameOID.ORGANIZATION_NAME, "My Web Server Inc."),
        x509.NameAttribute(NameOID.COMMON_NAME, "myserver.example.com"),
    ])
).add_extension(
    x509.SubjectAlternativeName([x509.DNSName("myserver.example.com")]),
    critical=False,
).sign(server_private_key, hashes.SHA256())

# The CA reviews the CSR and issues a certificate.
server_cert = x509.CertificateBuilder().subject_name(
    csr.subject
).issuer_name(
    ca_cert.subject  # The CA is the issuer.
).public_key(
    csr.public_key()
).serial_number(
    x509.random_serial_number()
).not_valid_before(
    datetime.now(timezone.utc)
).not_valid_after(
    datetime.now(timezone.utc) + timedelta(days=365)
).add_extension(
    x509.SubjectAlternativeName([x509.DNSName("myserver.example.com")]),
    critical=False,
).sign(ca_private_key, hashes.SHA256())  # Signed with the CA's private key.

print(f"\nServer Certificate:")
print(f"  Subject: {server_cert.subject.rfc4514_string()}")  # The server's identity
print(f"  Issuer:  {server_cert.issuer.rfc4514_string()}")   # Who vouched for it (the CA)
print(f"  Valid:   {server_cert.not_valid_after.date()}")

# As a client, I would have the CA's public key (from ca_cert).
# I can verify the server's certificate was truly signed by my trusted CA.
# This is what your browser does every time you visit an HTTPS website.
Enter fullscreen mode Exit fullscreen mode

This chain of trust—from a few root CAs your computer knows about, to intermediate CAs, down to your website's certificate—is what makes HTTPS work. It ensures you're really talking to your bank's server and not an imposter.

Putting it all together, these eight techniques form the building blocks. You use symmetric encryption (like AES-GCM) for speed once you have a key. You use asymmetric encryption (like RSA) or a key exchange (like Diffie-Hellman) to securely establish that key. You use hashing (like bcrypt) to protect passwords. You use signatures (like ECDSA) to verify software and messages. You use certificates to establish trust in public keys. And you always use secure randomness from secrets.

The most important lesson isn't just the code, but understanding what each tool is for. Don't use RSA to encrypt large files—it's slow. Use it to encrypt a symmetric key. Don't store passwords, store strong, salted hashes. Don't roll your own crypto combinations; use established modes like GCM or libraries like Fernet that do it correctly for you. Cryptography is easy to get wrong, but with these fundamental techniques and careful library choices, you can build systems that are genuinely secure.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)