Test Assumptions Adversarially
Learn to test assumptions with adversarial thinking in this Security foundations tutorial — hands-on steps, troubleshooting, and what to study next.
Focus: test assumptions with adversarial thinking
You've probably been burned by a bug that only appears when an API returns an empty list, or a config file missing a key, or a user pasting in something unexpected. In security, those little assumptions are where attackers live. This lesson teaches you to test assumptions with adversarial thinking — a disciplined way to hunt for the gaps in your mental model before someone else does. By the end, you'll turn vague paranoia into a repeatable process that catches real vulnerabilities.
The problem this lesson solves
Every system is built on assumptions. "The user will always send a valid token." "The database will never return a null here." "This input field only accepts numbers." Now replace "will" with "might not" and ask: what happens then?
Attackers don't follow your happy path. They send oversized payloads, malformed headers, negative numbers, missing fields, double-encoded URLs — anything that breaks your implicit contract. When you don't test your assumptions, you leave the door open for:
- Injection attacks because you assumed input is safe.
- Broken access control because you assumed the UI hides buttons.
- Denial of service because you assumed requests are reasonable.
- Data leaks because you assumed logs don't contain secrets.
The cost of an untested assumption is measured in breaches, not bugs. And unlike a crash, a security failure often happens silently, without alerting you. This lesson gives you a systematic way to surface those assumptions, challenge them, and fix the gaps before they become exploits.
Core concept / mental model
Adversarial thinking is the practice of looking at your own system as an enemy would. It's not about being paranoid; it's about being precise. You identify the assumptions you're making, then ask: "What would an attacker do with the opposite?"
Imagine your authentication flow. Your mental model says:
- The client sends a valid JWT.
- The server verifies the signature.
- The user is authorized for the requested resource.
An attacker's mental model flips every step:
- What if the JWT is forged with
alg: none? - What if the signature verification is skipped when the header is unusual?
- What if the user ID in the token doesn't match the resource owner?
Definitions: Assumption = a condition you believe is always true. Adversarial thinking = testing that condition by trying to break it. Threat model = the set of assumptions an attacker might exploit.
The mindset shift: Instead of asking "Does this work?", ask "When does this break?" Instead of "Is this safe?", ask "How could this be unsafe?" This is the difference between a tester and a security-minded engineer.
How it works step by step
Testing your assumptions adversarially is a process you can apply to any feature, API, or flow. Follow these five steps:
Step 1: List your explicit and implicit assumptions
Write down everything you assume about the input, state, and environment. Be ruthless — include the obvious ones.
For a password reset endpoint, you might list:
- The email exists in the database.
- The token is 32 chars long.
- The token expires after 15 minutes.
- The request comes from a real user, not a bot.
- The new password meets complexity rules.
Step 2: Flip each assumption
For each item, ask: What if the opposite were true? Write down the worst case.
- Email doesn't exist → does the response leak account existence?
- Token is 1 char long → can we brute-force it?
- Token never expires → can we replay it forever?
- Request is automated → does rate limiting exist?
- Password is 'password' → is there a blacklist?
Step 3: Test the flipped scenario
Use tools like curl, python, or a proxy to actually try it. Automate where possible so you can repeat it.
Step 4: Document the gaps
For each gap, note severity, exploitability, and whether it's real or theoretical.
Step 5: Fix and retest
Mitigate the issue, then re-run your attack to confirm the fix.
This cycle — assume, flip, test, fix — turns vague suspicion into concrete action.
Hands-on walkthrough
Let's practice with a simple Python function that looks up a user profile. We'll test our assumptions adversarially.
Example 1: Assumption about input type
# vulnerable.py
def get_user_profile(user_id):
# Assumption: user_id is always a valid integer from the database
query = f"SELECT name, email FROM users WHERE id = {user_id}"
# Imagine this is executed against a DB...
return execute(query) # pseudo
# Test 1: What if user_id is not an integer?
print(get_user_profile("1; DROP TABLE users;--"))
# Test 2: What if user_id is a negative number?
print(get_user_profile(-1))
# Test 3: What if user_id is None?
print(get_user_profile(None))
Expected output? None of these should cause a crash or SQL injection. The adversarial test uncovers that you need to validate input type and use parameterized queries.
Example 2: Assumption about function return values
def get_api_key(user):
# Assumption: every user has an API key
return user.get("api_key")
# Test 1: What if the user has no api_key key?
print(get_api_key({"name": "Alice"})) # None
# Test 2: What if api_key is an empty string?
print(get_api_key({"api_key": ""})) # ""
# Test 3: What if user is None?
print(get_api_key(None)) # AttributeError!
The function breaks with None — an unhandled edge case. An attacker could cause this by tampering with the user object.
Example 3: Test an authentication flow
# Try sending a request without a token
curl -X GET https://api.example.com/user/me
# Try sending a malformed token
curl -X GET https://api.example.com/user/me -H "Authorization: Bearer invalid"
# Try sending a token with alg=none
curl -X GET https://api.example.com/user/me -H "Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyIjoiYWRtaW4ifQ."
Each curl command tests an assumption about how the server handles missing/forged tokens. The last one checks for the classic JWT algorithm confusion vulnerability.
Example 4: Assert assumptions with a script
import unittest
def safe_lookup(user_id, db):
# adversarial fix: validate type and range
if not isinstance(user_id, int) or user_id <= 0:
raise ValueError("Invalid user_id")
return db.get(user_id)
class TestLookup(unittest.TestCase):
def test_string_id_rejected(self):
with self.assertRaises(ValueError):
safe_lookup("abc", {})
def test_negative_id_rejected(self):
with self.assertRaises(ValueError):
safe_lookup(-1, {})
def test_valid_id_returns_user(self):
self.assertEqual(safe_lookup(1, {1: {"name": "Alice"}}), {"name": "Alice"})
unittest.main()
Run it and see all tests pass, proving your defensive code works.
Compare options / when to choose what
Adversarial thinking isn't the only way to test assumptions. Here's how it compares to other approaches:
| Approach | Focus | Best for | Weakness |
|---|---|---|---|
| Adversarial thinking | Breaking your mental model | Security review, edge cases | Can miss unknown unknowns |
| Fuzzing | Random/structured malformed input | Crash bugs, memory safety | Not security-specific |
| Property-based testing | Invariants across random inputs | Correctness of algorithms | Hard to model security properties |
| Code review | Human analysis of logic | Logic flaws, design gaps | Slow, biased |
| Penetration testing | Real-world exploitation | High-level system security | Expensive, periodic |
When to choose adversarial thinking: - During design — before writing code. - When adding new features with trust boundaries. - When reviewing code for security-sensitive paths.
When not to: - For pure performance tuning. - When you need exhaustive input testing (use fuzzing).
You can combine them: use adversarial thinking to identify what to fuzz, then use property-based tests to encode those invariants.
Troubleshooting & edge cases
You might miss assumptions because you're too close to the code
Symptom: You review your own code and see nothing wrong. Fix: Take the attacker role explicitly — write down what you'd do if you were the enemy. Step away for an hour, or ask a colleague.
You assume the attacker knows less than they do
Symptom: You protect against novice attacks but not sophisticated ones. Fix: Assume the attacker has read your source code, knows your framework, and can inspect network traffic.
You test happy paths and forget hostile inputs
Symptom: Your tests cover valid data but not malformed or empty. Fix: For every function, ask "What if input is: empty, wrong type, out of range, None, or tampered?" Write a test for each.
You patch the symptom, not the root assumption
Symptom: You fix a specific attack but the same pattern recurs elsewhere. Fix: Identify the underlying assumption ("user_id is always safe") and fix it centrally, e.g., a validation layer.
Edge case: timing attacks
Assumption: Checking a token takes the same time whether it's right or wrong. Attack: Measure response time to guess a token character. Fix: Use constant-time comparison like hmac.compare_digest in Python.
Edge case: unicode and encoding
Assumption: The input is ASCII. Attack: Use %2e%2e or unicode homoglyphs to bypass path filters. Fix: Canonicalize input before validation.
Remember: if you can't test a scenario, your assumption is untested — and that's a risk.
What you learned & what's next
You've learned the core concept of testing assumptions with adversarial thinking: list assumptions, flip them, test, fix, and retest. You've seen how to apply this to Python code and API endpoints, and how to compare it with other testing strategies. You've also done hands-on exercises that turned vague paranoia into concrete vulnerabilities found and fixed.
You can now explain the core idea behind adversarial thinking — that every assumption is an attack surface — and you can complete a practical exercise to test assumptions in your own code.
Now that you can think like an attacker, the next critical skill is writing secure code mitigations — turning the gaps you found into robust defenses. In the next lesson, you'll learn how to harden code against the exact attacks you now know how to find.
Practice recap
Take a small function you wrote recently and list three assumptions you made about its inputs. For each, write a Python test that passes the opposite (e.g., a string where you expected an int) and see what breaks. Then implement a defensive fix and re-run your tests to confirm they pass.
Common mistakes
- Only testing the happy path and ignoring empty, None, or wrong-type inputs.
- Reviewing your own code without explicitly writing down assumptions first.
- Assuming the attacker doesn't know your framework or source code.
- Patching a single vulnerability without fixing the underlying assumption that caused it.
- Forgetting to test timing behavior — constant-time comparisons are needed for tokens.
Variations
- Property-based testing with Hypothesis to automatically generate hostile inputs.
- Fuzzing with tools like AFL or Python's
atherisfor malformed input crash discovery. - Using security linters like Bandit to statically detect common assumption violations (e.g., SQL injection).
Real-world use cases
- Reviewing an authentication API endpoint for edge cases that could allow token forgery or bypass.
- Testing a file upload feature for path traversal by flipping assumptions about filename sanitization.
- Auditing a payment integration for logic flaws when the amount is zero, negative, or a string.
Key takeaways
- Adversarial thinking means asking 'how could this break?' instead of 'does this work?'.
- Every assumption you make is a potential attack surface — list them explicitly.
- The core loop is: assume, flip, test, fix — repeat until no gaps remain.
- Test hostile inputs quickly with curl or Python, but encode your checks as unit tests.
- Choose adversarial thinking for security edge cases; fuzzing and property-based testing complement it.
- Fix root assumptions centrally, not symptoms, to avoid recurring vulnerabilities.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.