Skip to content

How Symmetric Encryption (Fernet) Keeps Local Credentials Safe on Disk

Desktop apps that talk to servers over SSH or an API often need to remember a password or key between launches. Asking the user to retype it every time isn’t realistic, but saving it as plain text in a config file is risky — the moment that file ends up in a backup, a sync folder, or gets shared with someone for debugging, the credential is exposed. This post looks at how symmetric encryption solves that specific problem, using Python’s cryptography library and its Fernet recipe as a concrete example.

Note: Symmetric encryption uses the same key for both encrypting and decrypting. That’s different from the SSH keys covered in SSH key types (RSA / ED25519 / ECDSA) — what actually differs, and which one to pick, which are asymmetric — a public/private key pair. Asymmetric encryption exists to let two separate parties authenticate or communicate without ever sharing a secret. Symmetric encryption fits a different case: a single program encrypting data now so that the same program can read it back later.

Why symmetric encryption is the right tool here

SSH connections and API calls involve two separate parties — the local machine and a remote server — so a secure way to exchange keys matters, and that’s exactly what asymmetric cryptography is built for. Local credential storage is a different problem: the app encrypts its own configuration and later decrypts it for its own use. There’s no second party to negotiate a key with. A single key, kept somewhere safe, is enough. Symmetric algorithms are also computationally lighter, which suits this “lock your own box with your own key” use case well.

What Fernet actually bundles together

Fernet, part of Python’s cryptography library, packages a set of well-understood primitives into one safe-to-use recipe:

  • AES (Advanced Encryption Standard) encrypts the payload itself.
  • HMAC (Hash-based Message Authentication Code) signs the ciphertext so tampering can be detected.
  • An embedded timestamp records when the token was created, which can later support expiration checks.
  • URL-safe Base64 encoding turns the whole thing into a printable string that drops cleanly into JSON or a text file.

The important part is that Fernet handles authentication, not just confidentiality. Plain encryption without an integrity check can still be tampered with — an attacker without the key generally can’t read the plaintext, but might still be able to flip bits in the ciphertext and corrupt it in a way that goes unnoticed. Fernet’s built-in HMAC check catches that: if a token has been altered since it was created, decryption fails outright.

from cryptography.fernet import Fernet

key = Fernet.generate_key()          # 32 random bytes, URL-safe base64 encoded
f = Fernet(key)

token = f.encrypt(b"my-secret-password")   # encrypt -> token
plain = f.decrypt(token)                   # decrypt -> original bytes

Where the key lives, and how not to lose it

The hardest part of using symmetric encryption in practice usually isn’t the algorithm — it’s key management. Anyone holding the key can decrypt the data, and losing the key makes the encrypted data permanently unreadable. That’s a fundamentally different failure mode than a forgotten password, which can just be reset, so persisting the key safely deserves real care.

This app generates a key the first time it runs, and stores it in a hidden file under the user’s home directory with permissions restricted to the file’s owner. On later launches it reads back the same key. As a safeguard against that file disappearing — accidental deletion, a corrupted profile directory — a second copy of the same key is kept in another location, and at startup both locations are checked so that a missing copy can be restored from whichever one still exists.

# Simplified: try the primary location, fall back to the backup,
# and re-sync whichever copy is missing.
key = load_from(PRIMARY) or load_from(BACKUP) or Fernet.generate_key()
sync_to_both_locations(key)

Keeping the key in more than one place and reconciling them on every startup is unglamorous compared to the encryption itself, but it matters just as much in practice. A single storage location turns any accidental deletion or disk hiccup directly into unrecoverable data.

Letting a value announce its own encryption state

One more detail worth noting: every encrypted value in this app is prefixed with ENC: before being saved.

def encrypt_value(value: str) -> str:
    if value.startswith("ENC:"):
        return value  # already encrypted, skip
    encrypted = fernet.encrypt(value.encode()).decode()
    return f"ENC:{encrypted}"

That prefix solves two practical problems at once. First, it makes encryption idempotent — calling it twice on an already-encrypted value won’t double-encrypt it. Second, it lets old plaintext data and newly encrypted data coexist in the same file without ambiguity: the value itself tells you which state it’s in. That means encryption support can be added to an existing app without a one-time migration pass — new writes get encrypted immediately, and old entries only get encrypted the next time they’re written.

Encrypting fields by allow-list, not by default

Rather than encrypting an entire configuration blob, this app exposes a helper that takes an explicit list of keys to encrypt within a dictionary — the caller decides what counts as sensitive.

def encrypt_dict(data: dict, keys_to_encrypt: list) -> dict:
    result = data.copy()
    for k in keys_to_encrypt:
        if k in result and result[k]:
            result[k] = encrypt_value(str(result[k]))
    return result

# Only the genuinely sensitive fields are named explicitly
site = encrypt_dict(site, ["ssh_password", "api_key"])

Encrypting non-sensitive fields like a site name or URL would make the config file unreadable and hard to diff by hand for no real benefit. An explicit allow-list keeps the sensitive fields protected while leaving everything else plain and inspectable.

Failing soft when decryption goes wrong

Finally, decryption here doesn’t raise on failure — it just returns the value unchanged.

def decrypt_value(value: str) -> str:
    try:
        return fernet.decrypt(value[4:].encode()).decode()
    except Exception:
        return value  # return as-is rather than raising

That choice matters if a config file ever gets loaded with the wrong key (say, one generated on a different machine) or gets corrupted by a manual edit. Instead of crashing the whole app, one unreadable field is returned untouched while everything else keeps loading normally. A degraded state — one field still garbled — is a better outcome than the app failing to start at all.

Summary

Design piece What it’s for
Fernet (AES + HMAC + timestamp) Encryption with built-in tamper detection
Key stored in two locations with mutual recovery Reduces the risk that losing the key means losing the data
ENC: prefix Prevents double-encryption; distinguishes old plaintext from new ciphertext
Allow-list-based encrypt_dict Encrypts only sensitive fields, keeps the rest readable
Decryption fails soft, not hard One corrupted field doesn’t take down the whole app

Symmetric encryption itself is a well-established, boring technology. What actually determines whether it works safely in a real app is everything around it — where the key lives and how it survives being lost, how encrypted and plaintext values are told apart, and what happens when decryption doesn’t go as planned.