Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Security
Labs

Secure Data at Rest for Python

Globomantics is undergoing a massive security overhaul to comply with strict new data protection regulations. As a Python developer on the security engineering team, you have been tasked with auditing and reviewing an upgraded legacy application. Previously, the application stored user passwords in plaintext, left sensitive local configuration files exposed, and hardcoded critical API keys directly into the source code. In this lab, you will review the complete secure code provided to understand how security-first development practices are implemented. You will examine the authentication module to understand how user passwords are securely hashed, analyze the symmetric AES encryption lock down local data at rest, and review a robust secrets management solution that utilizes python-dotenv for local development and AWS Key Management SDKs (boto3) for secure production deployments.

Lab platform
Lab Info
Last updated
Jun 08, 2026
Duration
40m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. Challenge

    Analyze secure password storage with Argon2id

    What is password hashing

    Storing user passwords in plaintext means a single database leak compromises every account. Hashing transforms each password into a one-way fingerprint so the original is never recoverable from disk. The current best practice for new applications is Argon2id, the OWASP-recommended algorithm. Argon2id is memory-hard, which forces brute-force attackers to spend RAM (not just CPU cycles) per guess and effectively neutralizes GPU and ASIC attacks.

    Globomantics uses Argon2id through the passlib library. The library provides a stable API on top of the lower-level argon2-cffi package, so future migrations to a different scheme require no call-site changes.


    Why a salt matters

    Without a per-password salt, identical passwords produce identical hashes, making precomputed rainbow-table attacks trivial. The argon2.hash() function generates a fresh random salt on every call and embeds it into the output string, so two users with the same password produce different hashes.


    What you will do

    You will open the auth.py module, examine the register() and verify() functions, register a user from the CLI, inspect the resulting hash on disk, and confirm that an incorrect password is rejected.

    --- The src/globomantics/auth.py module is the only place in the codebase that hashes or verifies passwords. By centralizing every credential operation in a single file, it guarantees that all code paths use the same algorithm and prevents accidental plaintext leaks elsewhere in the application. In the auth.py file, the register() function calls argon2.hash(password) which generates a random salt internally and produces a hash string starting with argon2id. Only that hash string is written to users.json via the _save() helper method.

    def register(username: str, password: str, users_file: Path = DEFAULT_USERS_FILE) -> None:
        """Hash the password with Argon2id and persist the result.
    
        Each call generates a fresh random salt internally, so two users with the
        same password produce different hashes.
        """
        users = _load(users_file)
        if username in users:
            raise AuthError(f"User '{username}' already exists.")
        users[username] = argon2.hash(password)
        _save(users_file, users)
    ``` In the `auth.py` file, the `verify()` function loads the stored hash for the username and calls `argon2.verify(password, stored)`, which performs a constant-time comparison. If the username does not exist, a dummy verification still runs against `argon2.hash("dummy")` before returning False.  
    
    Constant-time comparison prevents timing attacks where an attacker measures response duration to infer correct characters.  
    
    ```py
    def verify(username: str, password: str, users_file: Path = DEFAULT_USERS_FILE) -> bool:
        """Return True iff the password matches the stored hash for the user.
    
        Uses passlib's constant-time verify to avoid leaking timing information
        about which characters matched.
        """
        users = _load(users_file)
        stored = users.get(username)
        if stored is None:
            # Hash a dummy password anyway so timing does not reveal whether the
            # username existed. Cheap defense-in-depth.
            argon2.verify(password, argon2.hash("dummy"))
            return False
        return argon2.verify(password, stored)
        ``` `app register <username>` invokes `auth.register()` which writes a freshly hashed credential to `users.json` in the current working directory. The CLI prints `User 'alice' registered.` on success.  
    
    ![User Registration](https://s2.pluralsight.com/code-labs/public/4aee5ba2-3994-42a6-9e47-c8cadab8e0de/e603243824d175ceddb6-1778857929.png) `users.json` file contains a JSON object mapping alice to a string starting with `$argon2id$v=19$m=...$t=...$p=...$<salt>$<hash>`. The numbers after `m=`, `t=`, and `p=` are the Argon2 memory, time, and parallelism parameters baked into the hash.  
    
    
    ![users.json file](https://s2.pluralsight.com/code-labs/public/4aee5ba2-3994-42a6-9e47-c8cadab8e0de/7d7fd0c347bde217ce8d-1778858253.png)
     #### Analysis
    The `app` CLI re-hashes the supplied password using the salt embedded in the stored hash in the `users.json` file and compares the two values in constant time. A match proves the original password was supplied and authenticated without the system ever needing to keep it.  
    
    ![successful login](https://s2.pluralsight.com/code-labs/public/4aee5ba2-3994-42a6-9e47-c8cadab8e0de/3e1b455b141249812534-1778863196.png) Even with full read access to `users.json`, an attacker cannot reverse the stored hash to recover the original password. The Argon2id verification step is the only path to authenticate, and it fails closed when the password does not match. ![invalid password](https://s2.pluralsight.com/code-labs/public/4aee5ba2-3994-42a6-9e47-c8cadab8e0de/90876ba2470b7c124b32-1778863307.png)
  2. Challenge

    Encrypt local configuration with Fernet symmetric AES

    All Fernet operations live in the src/globomantics/crypto.py file. The functions generate_key(), encrypt_file(), and decrypt_to_memory() form the complete envelope around the local config file. ### generate_key() function In the crypto.py file, the generate_key() function returns the result of Fernet.generate_key(), which is a fresh URL-safe base64-encoded 32-byte key. Each call produces an independent value.

    Generating the key directly from the library, rather than deriving it from a passphrase, guarantees a full 256 bits of entropy. A weak key derivation here would undermine all of AES's strength.

    def generate_key() -> bytes:
        """Generate a fresh URL-safe base64-encoded 32-byte Fernet key."""
        return Fernet.generate_key()
    

    --- ### encrypt_file() function In the crypto.py file, the encrypt_file() function reads the plaintext file as bytes, calls f.encrypt(plaintext), and writes the resulting token to config.enc stored at ciphertext_path. The Fernet token contains a version byte, a timestamp, the IV, the AES ciphertext, and the HMAC tag.

    Because Fernet produces an authenticated ciphertext, any tampering with the file on disk is detected at decryption time and raises InvalidToken. You do not need to write IV management or tag verification yourself.

    def encrypt_file(plaintext_path: Path, key: bytes, ciphertext_path: Path = DEFAULT_CIPHERTEXT_FILE) -> Path:
        """Read a plaintext file, encrypt the contents, and write a Fernet token to disk."""
        f = Fernet(key)
        plaintext = plaintext_path.read_bytes()
        token = f.encrypt(plaintext)
        ciphertext_path.write_bytes(token)
        return ciphertext_path
    

    --- ### decrypt_to_memory() function

    In the crypto.py file, the decrypt_to_memory() function decrypts the ciphertext file and returns the plaintext as bytes. It never writes the plaintext back to disk. A failed decryption raises CryptoError rather than returning a partial result.

    Returning bytes rather than writing to a file keeps the secret confined to the calling process's memory.

    def decrypt_to_memory(key: bytes, ciphertext_path: Path = DEFAULT_CIPHERTEXT_FILE) -> bytes:
        """Decrypt the ciphertext file and return the plaintext as bytes.
    
        The returned bytes live only in the caller's stack frame — this function
        never writes the plaintext to disk. Callers should avoid logging the
        return value or persisting it.
        """
        f = Fernet(key)
        if not ciphertext_path.exists():
            raise CryptoError(f"Ciphertext file '{ciphertext_path}' not found.")
        try:
            return f.decrypt(ciphertext_path.read_bytes())
        except InvalidToken as exc:
            raise CryptoError(
                "Decryption failed: ciphertext is corrupt or the key is wrong."
            ) from exc
    

    --- app config init-key calls crypto.generate_key() and writes the result to a file named secret.key in the current working directory.

    secret.key is the single piece of data that protects every encrypted config file in local mode.

    app config init-key

    --- String in secret.key is a single line of URL-safe base64 characters, 44 characters long, ending with =. This is the encoded form of 32 random bytes.
    Anyone holding this key can decrypt every file ever encrypted with it. Treat the contents with the same care as a root password — never log, email, or commit the value.

    cat secret-key

    --- config.example.json file documents the configuration key and values required to run the Python application.

    --- config.json file containing the following keys:

    • database_url — The PostgreSQL connection string the application uses to reach its local development database (globomantica) running on localhost:5432.
    • internal_endpoint — The base URL of the internal API the application communicates with.
    • feature_flags — Boolean switches that enable or disable specific features at runtime. In this configuration, - new_checkout is enabled and experimental_search is disabled.
    • seed_secrets — Placeholder credentials loaded by the application on startup, including a payment gateway key, an email service key, and a Fernet key placeholder () that you will use for encryption in the next step.

    cat config-json

    --- app config encrypt <config file location> reads config.json, encrypts the bytes with the key from secret.key, and writes the resulting Fernet token to config.enc.

    From this point onward, only a process holding secret.key can read the configuration. In a real deployment, the plaintext config.json would be deleted — only the ciphertext needs to ship.

    app config encrypt config-json

    --- config.enc holds an opaque blob. None of the original keys, URLs, or feature flag names from config.json are visible.

    An attacker with read access to config.enc learns nothing about the contents without secret.key.

    cat config-enc


    app config show loads secret.key, decrypts config.enc into memory, parses the bytes as JSON, and prints the result to stdout. The plaintext is never recreated as a file on disk.

    The decrypted content lives only inside the running CLI process. Once the command exits, the data is gone from memory. Any subsequent access requires re-running the decrypt path with the key.

    app config show

  3. Challenge

    Manage secrets for local development and production

    The hardcoded credentials problem

    API keys committed to source control leak forever — even after a force-push, the value lives on in clones, mirrors, and search indexes. Different environments need different secrets (dev keys versus production keys), and the application code should depend on a secrets interface rather than on a specific storage location.


    Local development with python-dotenv

    A .env file holds local-only secrets and is excluded from version control. The python-dotenv library loads the file into os.environ at startup, so the rest of the application reads from environment variables without knowing where they came from. A committed .env.example file documents the required keys without leaking real values.


    Production with AWS Secrets Manager

    AWS Secrets Manager stores secrets server-side with audit logging and IAM-controlled access. The boto3 SDK fetches secrets at runtime, so nothing sensitive lives in the deployment artifact. The moto library ships a local dummy of the AWS API surface, which lets you exercise the same boto3 code path fully offline — no AWS account, credentials, or internet required.


    What you will do

    You will open secrets.py, examine the two provider classes and the factory function, fetch a secret in local mode, start the mock AWS server, seed it with secrets, fetch the same secret in prod mode, and run the integrated end-to-end demo.

    --- The src/globomantics/secrets.py module defines a SecretsProvider Protocol and two concrete implementations: DotenvSecretsProvider for local mode and AwsSecretsProvider for prod mode.

    Both classes expose the same get(name) method, so the calling code remains identical regardless of the backend. Switching environments becomes a single environment variable change rather than a code change.

    --- ### DotenvSecretsProvider constructor In the secrets.py file, the DotenvSecretsProvider constructor calls load_dotenv(env_file, override=False) which parses .env to fetch the secrets.

    Reading mode from os.environ means the same code transparently picks up secrets injected by Docker, systemd, or shell exports. A .env file is just one of many possible sources to pass the secret in the local deployment

    class DotenvSecretsProvider:
        """Reads secrets from a .env file via python-dotenv (local development only)."""
    
        def __init__(self, env_file: str = ".env") -> None:
            load_dotenv(env_file, override=False)
    
        def get(self, name: str) -> str:
            value = os.environ.get(name)
            if value is None:
                raise SecretsError(
                    f"Secret '{name}' not found in environment. "
                    f"Add it to your .env file or switch to prod mode."
                )
            return value
    

    --- ### AwsSecretsProvider constructor

    In the secrets.py file, the AwsSecretsProvider constructor builds a boto3 Secrets Manager client pointed at endpoint_url=http://localhost:5000. The get() method calls get_secret_value(SecretId=name) and returns the SecretString field from the response.

    In a real deployment, you would omit endpoint_url, omit the static credentials, and let boto3 pick up an IAM role from the default credential chain.

    class AwsSecretsProvider:
        """Reads secrets from AWS Secrets Manager.
    
        In production this would talk to the real AWS endpoint. For the lab we
        point boto3 at a local moto_server instance via ``endpoint_url``. The
        application code path is identical — only the endpoint differs.
        """
    
        def __init__(
            self,
            endpoint_url: str = MOCK_AWS_ENDPOINT,
            region_name: str = MOCK_AWS_REGION,
        ) -> None:
            # The credentials passed here are placeholders that moto_server accepts
            # without validation. In real prod, boto3 picks up credentials from the
            # default chain (env vars, instance role, etc.) and you would not pass
            # an endpoint_url at all.
            self._client = boto3.client(
                "secretsmanager",
                endpoint_url=endpoint_url,
                region_name=region_name,
                aws_access_key_id="testing",
                aws_secret_access_key="testing",
            )
    
        def get(self, name: str) -> str:
            try:
                response = self._client.get_secret_value(SecretId=name)
            except (BotoCoreError, ClientError) as exc:
                raise SecretsError(
                    f"Failed to fetch secret '{name}' from AWS Secrets Manager: {exc}"
                ) from exc
            return response["SecretString"]
    

    --- ### get_provider() function

    In the secrets.py file, get_provider() resolves the active mode from the explicit mode argument, or the APP_MODE environment variable, and a default of local. It returns either an AwsSecretsProvider or a DotenvSecretsProvider.

    class AwsSecretsProvider:
        """Reads secrets from AWS Secrets Manager.
    
        In production this would talk to the real AWS endpoint. For the lab we
        point boto3 at a local moto_server instance via ``endpoint_url``. The
        application code path is identical — only the endpoint differs.
        """
    
        def __init__(
            self,
            endpoint_url: str = MOCK_AWS_ENDPOINT,
            region_name: str = MOCK_AWS_REGION,
        ) -> None:
            # The credentials passed here are placeholders that moto_server accepts
            # without validation. In real prod, boto3 picks up credentials from the
            # default chain (env vars, instance role, etc.) and you would not pass
            # an endpoint_url at all.
            self._client = boto3.client(
                "secretsmanager",
                endpoint_url=endpoint_url,
                region_name=region_name,
                aws_access_key_id="testing",
                aws_secret_access_key="testing",
            )
    
        def get(self, name: str) -> str:
            try:
                response = self._client.get_secret_value(SecretId=name)
            except (BotoCoreError, ClientError) as exc:
                raise SecretsError(
                    f"Failed to fetch secret '{name}' from AWS Secrets Manager: {exc}"
                ) from exc
            return response["SecretString"]
    
    

    --- The committed .env.example file documents the required keys with placeholder values. The real .env file is git-ignored. APP_MODE defaults to local in the template.

    --- With default local mode, the CLI instantiates DotenvSecretsProvider, which loads .env into os.environ and reads the requested key. The output is local-dev-payment-key-123456.

    app get-secret PAYMENT_GATEWAY_API_KEY

    --- In the mock_aws/server.py module starts a ThreadedMotoServer on 127.0.0.1:5000 that emulates the AWS API surface for any service moto supports.

    A local mock removes every AWS dependency from the lab — no account, credentials, or internet required — while still exercising the same boto3 code paths a real production deployment would use.

    def main() -> None:
        server = ThreadedMotoServer(ip_address=HOST, port=PORT)
        server.start()
        print(f"Mock AWS running on http://{HOST}:{PORT}")
        print("Run 'mock-aws-seed' in another terminal to populate secrets.")
        print("Press Ctrl+C to stop.")
    ``` The `mock_aws/seed.py` script reads the `seed_secrets` block from `config.json` and creates each secret via the `boto3` `create_secret` call. 
    
     Output of `mock-aws-seed` lists `created PAYMENT_GATEWAY_API_KEY`, `created EMAIL_SERVICE_API_KEY`, and `created FERNET_KEY`.  
    
    A fresh `moto_server` starts with an empty Secrets Manager. Seeding makes the prod-mode demo deterministic and removes any need to manually create secrets through the AWS CLI or console.  
    
    ![mock-aws-seed](https://s2.pluralsight.com/code-labs/public/4aee5ba2-3994-42a6-9e47-c8cadab8e0de/b432c8a6057ce63d2387-1778870002.png)
    
    --- `--mode prod` flag overrides `APP_MODE` for this single command. The CLI uses `AwsSecretsProvider`, calls the mock AWS endpoint, and prints `prod-payment-key-9f8e7d6c5b4a3210`.  
    
    ![app get-secret PAYMENT_GATEWAY_API_KEY --mode prod](https://s2.pluralsight.com/code-labs/public/4aee5ba2-3994-42a6-9e47-c8cadab8e0de/91d65d51d231c4b2b390-1778870114.png)
About the author

Sahil Gupta is highly skilled in Product Security, specializing in DevSecOps and Application Security. They are passionate about enhancing security posture & delivering robust and secure solutions.

Real skill practice before real-world application

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Learn by doing

Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.

Follow your guide

All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.

Turn time into mastery

On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.

Get started with Pluralsight