sethserver.com
A glowing violet vault door with a keyhole set into a dark wall of faint geometric light patterns.

Encryption Key Generator: AES-256, Fernet and Hex Keys

By Seth Black • Updated: September 17, 2026

Pick a key type, copy the encoding your code expects, done. Keys come from crypto.getRandomValues in your browser, which draws on your operating system's random number generator, and nothing is sent to a server. For keys that will protect real data, I would still rather you run one of the offline commands further down on the machine that needs the key. Fewer places a key has been, fewer places it can leak from.

Looking for something that signs rather than encrypts? The secret key generator handles framework secrets (Flask, Django, Laravel, Rails and friends), the Django secret key generator matches Django's format exactly, and the JWT secret generator and API key generator cover those.

Which key do you need?

You are usingPickSize
AES-GCM in Python, Node, Go, Java, .NET, or Web CryptoAES-256, hex or base6432 bytes
Python cryptography.fernet.Fernet (Airflow's fernet_key setting uses the same format)Fernet key32 bytes, 44 characters
A config value that says "random hex, N characters"Random hex string, unit hex characterswhatever it asks for
Testing AES-GCM code by handAES-256 key plus a nonce32 + 12 bytes
Anything that says "128-bit key"AES-12816 bytes

An AES key has no structure. It is just random bytes of the right length: 16 bytes for AES-128, 24 for AES-192, 32 for AES-256. Hex and base64 are only ways of writing those bytes down as text. Your code has to decode them back to bytes before handing them to the cipher. Passing the 64 character hex string where 32 bytes are expected is one of the most common bugs I see, and some libraries will not even complain.

A key is not a password

A 32 byte random key has 256 bits of entropy. A good human password has maybe a fraction of that, and a typical one has far less. If you encrypt with sha256("hunter2") as the key, the ciphertext is exactly as strong as "hunter2", and an attacker with the ciphertext can guess offline as fast as their hardware allows.

When a human has to supply the secret, run it through a password-based key derivation function. KDFs are deliberately slow and, in the better ones, memory hungry, so each guess costs the attacker real work:

Every one of them needs a random salt, generated per password and stored next to the ciphertext. The salt is not secret. It exists so that two people with the same password end up with different keys.

If no human is involved (a service encrypting data, a backup job, an app encrypting a database column), skip passwords entirely and use a random key from this page, openssl rand, or your KMS.

AES-GCM is the sensible default

If you are picking a cipher today and nobody is handing you requirements, use AES-256-GCM. GCM is an authenticated mode: it encrypts and also produces a tag that detects any change to the ciphertext. Decryption with a modified ciphertext, the wrong key, or the wrong nonce fails loudly instead of returning garbage. Older setups like AES-CBC need a separate MAC bolted on, and getting that composition right is where a lot of real-world bugs have come from.

GCM needs three things per message:

Here is the full round trip in Python with the cryptography package:

import base64, os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

key = base64.b64decode(os.environ["DATA_KEY"])   # 32 bytes
aesgcm = AESGCM(key)

nonce = os.urandom(12)                            # new for EVERY message
ciphertext = aesgcm.encrypt(nonce, b"attack at dawn", b"record-42")

stored = nonce + ciphertext                       # nonce travels with the data

plaintext = aesgcm.decrypt(stored[:12], stored[12:], b"record-42")

And the same thing in Node, where you handle the tag yourself:

const crypto = require("crypto");

const key = Buffer.from(process.env.DATA_KEY, "base64");  // 32 bytes
const iv = crypto.randomBytes(12);                        // new for EVERY message

const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([cipher.update("attack at dawn", "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();                          // 16 bytes

const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");

About the nonce on this page

The nonce mode exists for testing, test vectors and poking at code by hand. In a real program you generate the nonce inside the encrypt function, every single time, as in the examples above. The cryptography docs are blunt about why: reusing a nonce with the same key "compromises the security of any message with that nonce and key pair." With GCM that means an attacker can recover the XOR of the two plaintexts and can start forging messages. A nonce copied from a web page into a config file gets reused by definition.

Nonces are not secret. Store them in front of the ciphertext, as above. Hiding them buys nothing and makes decryption annoying.

Fernet keys

Fernet is the "hard to misuse" recipe in Python's cryptography library. A Fernet key is 32 random bytes written as URL-safe base64 with padding, which always comes out to 44 characters ending in =. This is Fernet.generate_key(), straight from the library source:

@classmethod
def generate_key(cls) -> bytes:
    return base64.urlsafe_b64encode(os.urandom(32))

Internally, Fernet splits the key in half: the first 16 bytes are the HMAC-SHA256 signing key and the last 16 bytes are the AES-128-CBC encryption key. Each token also carries a timestamp, so you can reject tokens older than a TTL on decrypt.

from cryptography.fernet import Fernet

f = Fernet(os.environ["FERNET_KEY"])
token = f.encrypt(b"my deep dark secret")
f.decrypt(token)              # b"my deep dark secret"
f.decrypt(token, ttl=3600)    # also fails if the token is older than an hour

A Fernet key is not interchangeable with a plain base64 AES key. The encoding uses - and _ instead of + and /, and the bytes are split into two keys. Use the Fernet mode for Fernet and the AES modes for everything else.

Generate encryption keys offline

# AES-256 key (use 16 for AES-128, 24 for AES-192)
openssl rand -hex 32
openssl rand -base64 32

# Random hex string of N bytes (2N characters)
openssl rand -hex 20

# 12 byte AES-GCM nonce, for testing
openssl rand -hex 12

# Python
python3 -c "import secrets; print(secrets.token_hex(32))"
python3 -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"

# Fernet key
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
openssl rand -base64 32 | tr '+/' '-_'

# Node
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

The tr trick works because a Fernet key is standard base64 with two characters swapped, and 32 bytes of base64 always ends in a single = either way.

To check a key decodes to the size you think it does:

python3 -c "import base64, sys; print(len(base64.b64decode(sys.argv[1])))" 'YOUR_BASE64_KEY'
python3 -c "import sys; print(len(bytes.fromhex(sys.argv[1])))" YOUR_HEX_KEY

Where encryption keys should live

The golden rule: not next to the data they protect. An encrypted database backup with the key in the same bucket is an unencrypted backup with extra steps.

In rough order of preference:

  1. A key management service (AWS KMS, Google Cloud KMS, Azure Key Vault, HashiCorp Vault's transit engine). The master key never leaves the service, every use is logged, and access is controlled by IAM policy.
  2. A secrets manager (AWS Secrets Manager, Google Secret Manager, Vault's KV store, Doppler, 1Password for teams). The app fetches the key at startup, and access is logged and revocable.
  3. Environment variables injected by your platform. Fine for a lot of apps, but they tend to leak into crash dumps, /proc, debug pages and child processes, so keep an eye on what inherits them.

Never in the repository, never in a Docker image layer, never in a log line, and never in a ticket or chat message "just for a minute." Keep production keys separate from development keys, and give each service its own key so one compromise stays one compromise. I wrote more about scoping access in the principle of least privilege in the AI age.

Key rotation and envelope encryption

Encryption keys are harder to rotate than signing keys, because data encrypted with the old key stays encrypted with it until you re-encrypt it. The usual pattern:

  1. Tag every ciphertext with a key ID (a byte or a short prefix is enough).
  2. Add the new key and start encrypting with it. Keep the old key for decryption only.
  3. Re-encrypt old records in the background, or lazily whenever they are read.
  4. Retire the old key once nothing references it.

Fernet has this built in: MultiFernet([Fernet(new), Fernet(old)]) encrypts with the first key, tries each key on decrypt, and rotate(token) re-encrypts a token under the first key.

Envelope encryption is how the big systems make rotation cheap. Each object gets its own random data key. You encrypt the object with the data key, then encrypt the data key with a key encryption key held in a KMS, and store the wrapped data key alongside the object. To rotate the master key you only re-wrap small data keys, not terabytes of data, and the master key itself never leaves the KMS. AWS KMS's GenerateDataKey call exists for exactly this: it hands back the data key in plaintext for immediate use and in encrypted form for storage.

If you are building this for a single app with a single key, you probably do not need envelope encryption yet. Tag your ciphertexts with a key ID from day one anyway. It is the one decision that is painful to add later.

Common mistakes

For the bigger picture, here are my opinionated security best practices.

Encryption Key FAQ

How do I generate an AES-256 key?

An AES-256 key is 32 random bytes from a cryptographically secure random number generator. Pick AES-256 above and copy it as hex (64 characters) or base64 (44 characters). Offline, openssl rand -hex 32 or openssl rand -base64 32 produce the same thing.

Should I use AES-128 or AES-256?

Both are secure. AES-256 is the more common default, is often required by compliance rules, and leaves a larger safety margin, while AES-128 is slightly faster. If nothing forces your hand, use AES-256 in GCM mode. AES-192 works but is rarely used, and some libraries do not support it.

Can I use a password as an encryption key?

Not directly. A password has far less entropy than a random key and is not the right length. Run it through a password-based key derivation function such as Argon2id, scrypt or PBKDF2 with a random salt, and store the salt next to the ciphertext.

Does the IV or nonce need to be secret?

No. An AES-GCM nonce is stored or sent alongside the ciphertext in the clear. What matters is that the same nonce is never used twice with the same key. Generate a fresh 12 byte nonce for every message in your code, rather than reusing one from this page.

What is a Fernet key?

A Fernet key is 32 random bytes encoded as URL-safe base64 with padding, which is 44 characters ending in an equals sign. Python's cryptography library uses the first 16 bytes as an HMAC-SHA256 signing key and the last 16 bytes as an AES-128-CBC encryption key.

Is a random hex string the same as an encryption key?

A hex string of 32 random bytes decodes to a valid AES-256 key, so yes if your code decodes the hex first. The common bug is passing the 64 character hex text itself as the key, which some libraries reject and others quietly treat as 64 bytes of ASCII.

Is it safe to generate encryption keys in a browser?

The keys on this page come from crypto.getRandomValues, which is the operating system random number generator exposed to the browser, and nothing is sent to a server. For production keys, generating them where they will live, with openssl, your language's secrets module, or a KMS, removes the browser and clipboard from the picture entirely.

Related tools: secret key generator, Django secret key generator, Flask secret key generator, JWT secret generator, API key generator, and the random passphrase generator for the passwords you feed a KDF.

-Sethers

Newsletter

One email, once a week.

Notes on databases, systems, and the occasional strong opinion about Python. No spam, unsubscribe anytime.

More to read

All tools →

My Password Generators Had 53 Bits of Entropy

Sep 21, 2026

API Key Generator with Prefix, Checksum and SHA-256

Sep 17, 2026

Bcrypt Generator: Hash and Verify Passwords in Your Browser

Sep 17, 2026