- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Security
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 Info
Table of Contents
-
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
passliblibrary. The library provides a stable API on top of the lower-levelargon2-cffipackage, 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.pymodule, examine theregister()andverify()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.pymodule 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 theauth.pyfile, theregister()function callsargon2.hash(password)which generates a random salt internally and produces a hash string starting with argon2id. Only that hash string is written tousers.jsonvia 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.  `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.  #### 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.  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.  -
Challenge
Encrypt local configuration with Fernet symmetric AES
All Fernet operations live in the
src/globomantics/crypto.pyfile. The functionsgenerate_key(),encrypt_file(), anddecrypt_to_memory()form the complete envelope around the local config file. ###generate_key()function In thecrypto.pyfile, thegenerate_key()function returns the result ofFernet.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 thecrypto.pyfile, theencrypt_file()function reads the plaintext file as bytes, callsf.encrypt(plaintext), and writes the resulting token toconfig.encstored atciphertext_path. The Fernettokencontains 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()functionIn the
crypto.pyfile, thedecrypt_to_memory()function decrypts the ciphertext file and returns the plaintext asbytes. It never writes the plaintext back to disk. A failed decryption raisesCryptoErrorrather 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-keycallscrypto.generate_key()and writes the result to a file namedsecret.keyin the current working directory.secret.keyis the single piece of data that protects every encrypted config file in local mode.
--- String in
secret.keyis 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.
---
config.example.jsonfile documents the configuration key and values required to run the Python application.---
config.jsonfile containing the following keys:database_url— The PostgreSQL connection string the application uses to reach its local development database (globomantica) running onlocalhost: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_checkoutis enabled andexperimental_searchis 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.

---
app config encrypt <config file location>readsconfig.json, encrypts the bytes with the key fromsecret.key, and writes the resulting Fernet token toconfig.enc.From this point onward, only a process holding
secret.keycan read the configuration. In a real deployment, the plaintextconfig.jsonwould be deleted — only the ciphertext needs to ship.
---
config.encholds an opaque blob. None of the original keys, URLs, or feature flag names fromconfig.jsonare visible.An attacker with read access to
config.enclearns nothing about the contents withoutsecret.key.
app config showloadssecret.key, decryptsconfig.encinto 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.

-
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
.envfile holds local-only secrets and is excluded from version control. Thepython-dotenvlibrary loads the file intoos.environat startup, so the rest of the application reads from environment variables without knowing where they came from. A committed.env.examplefile 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
boto3SDK fetches secrets at runtime, so nothing sensitive lives in the deployment artifact. Themotolibrary ships a local dummy of the AWS API surface, which lets you exercise the sameboto3code 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.pymodule defines aSecretsProviderProtocol and two concrete implementations:DotenvSecretsProviderfor local mode andAwsSecretsProviderfor 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.--- ###
DotenvSecretsProviderconstructor In thesecrets.pyfile, theDotenvSecretsProviderconstructor callsload_dotenv(env_file, override=False)which parses.envto fetch the secrets.Reading mode from
os.environmeans the same code transparently picks up secrets injected by Docker, systemd, or shell exports. A.envfile is just one of many possible sources to pass the secret in the local deploymentclass 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--- ###
AwsSecretsProviderconstructorIn the
secrets.pyfile, theAwsSecretsProviderconstructor builds aboto3Secrets Manager client pointed atendpoint_url=http://localhost:5000. Theget()method callsget_secret_value(SecretId=name)and returns theSecretStringfield from the response.In a real deployment, you would omit
endpoint_url, omit the static credentials, and letboto3pick 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()functionIn the
secrets.pyfile,get_provider()resolves the active mode from the explicitmodeargument, or theAPP_MODEenvironment variable, and a default oflocal. It returns either anAwsSecretsProvideror aDotenvSecretsProvider.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.examplefile documents the required keys with placeholder values. The real.envfile is git-ignored.APP_MODEdefaults tolocalin the template.--- With default local mode, the CLI instantiates
DotenvSecretsProvider, which loads.envintoos.environand reads the requested key. The output islocal-dev-payment-key-123456.
--- In the
mock_aws/server.pymodule starts aThreadedMotoServeron127.0.0.1:5000that emulates the AWS API surface for any servicemotosupports.A local mock removes every AWS dependency from the lab — no account, credentials, or internet required — while still exercising the same
boto3code 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.  --- `--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`. 
About the author
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.