Breaking to Build: How CTF and Bug Bounty Hunting Rewires System Design

Software engineers are trained to create: read a requirement, design the happy path, write the code, pass tests, and ship. Authorized security practice adds a useful second question: what assumptions does this design make, and how could those assumptions fail?
Capture The Flag exercises and properly scoped vulnerability research are valuable because they train that question. The point is not to treat production systems as puzzles or to chase clever exploits. It is to bring adversarial thinking back into normal engineering work: define trust boundaries, verify authorization, reduce capability, log important decisions, and make unsafe behavior difficult by default.
That change in perspective has improved how I design APIs, multi-tenant services, background workers, and operational tooling. These are the system-design lessons that stick.
1. Data has provenance, not automatic trust
A database is durable storage, not a trust boundary. A value may have originated in a user form, webhook, import, support tool, partner API, or an earlier application bug. Reading it back from PostgreSQL does not make it safe for every new context.
The key distinction is between a value’s provenance and the sink where it will be used. A display name may be valid as text in a JSON response but unsafe if inserted as HTML, used in a shell command, or interpolated into a query. The correct defense depends on that destination.
untrusted input → validate shape at ingress → store as data
↓
encode or parameterize at each sink
For HTML, prefer framework auto-escaping and avoid raw HTML rendering. If rich user-authored HTML is a genuine product requirement, sanitize it with a maintained, context-appropriate library and keep the allowed elements and attributes intentionally small. For SQL, use parameterized queries. For commands, avoid shell interpolation; use APIs that pass arguments separately whenever possible.
// Good: the database driver keeps data separate from SQL syntax.
const project = await db.query(
"select id, name from projects where tenant_id = $1 and id = $2",
[tenantId, projectId]
);
// Prefer UI/framework escaping for text. Avoid injecting user data as raw HTML.
renderText(project.name);
“Sanitize everything” is too vague to be a reliable rule. Validate structure at ingress, preserve data as data, and encode or parameterize it for the specific output context. That approach also makes code review more precise: reviewers can ask, “what is the sink, and what protection matches it?”
2. Design authorization into every object lookup
Broken object-level authorization (often called IDOR or BOLA) is rarely fixed by choosing less guessable identifiers. UUIDs can reduce accidental enumeration, but they do not establish ownership or permission. The server must decide whether the authenticated principal may perform the requested action on the requested object.
I prefer authorization-first repository methods over a generic “load by id” followed by an easily forgotten policy check. The method shape makes tenant and actor context mandatory.
type Actor = { userId: string; tenantId: string; roles: string[] };
action async function loadProjectForRead(actor: Actor, projectId: string) {
return db.oneOrNone(
`select id, name, status
from projects
where id = $1
and tenant_id = $2`,
[projectId, actor.tenantId]
);
}
async function getProject(actor: Actor, projectId: string) {
const project = await loadProjectForRead(actor, projectId);
if (!project) throw new NotFoundError();
return project;
}
Production policies are often more nuanced than tenant membership: roles, project membership, delegated access, ownership, resource state, and action type may all matter. Centralize that policy where possible, test both allow and deny cases, and return responses that do not reveal unnecessary details about resources outside the caller’s scope.
3. Turn ambient power into explicit capabilities
Many high-impact failures begin with code that has more authority than it needs. A request handler that receives an all-powerful database client, a broad cloud credential, or a general-purpose administrative service can accidentally cross boundaries.
Instead, expose narrow operations that express intent: createInvoiceForTenant, readOwnProfile, or queueReportForProject. Pass an actor or capability explicitly. This makes authorization visible in APIs and reduces the chance that a helper silently acts with system-wide privileges.
type RefundCapability = {
actorId: string;
tenantId: string;
maxAmountCents: number;
};
async function requestRefund(
capability: RefundCapability,
invoiceId: string,
amountCents: number
) {
if (amountCents > capability.maxAmountCents) {
throw new ForbiddenError("Refund amount exceeds approval limit");
}
// Scope the invoice lookup and record the decision before side effects.
return refunds.create({ capability, invoiceId, amountCents });
}
This is not a replacement for database permissions, network controls, or policy enforcement. It is an application-level design habit that makes least privilege easier to preserve.
4. Threat model before implementation details harden
A short threat-modeling session early in a feature often prevents expensive rewrites later. It does not need a large ceremony. For a new endpoint or worker, I ask:
- What assets, actions, and data are worth protecting?
- Who are the actors, and which identities or credentials can they present?
- Where does untrusted data enter, cross a boundary, or reach a sensitive sink?
- Which actions are irreversible, high-value, or easy to replay?
- What evidence will help us detect and investigate misuse?
The output can be a few bullets in the pull request. The value is forcing assumptions into the open before they become interfaces that every later service depends on.
5. Secure defaults beat security checklists
Security controls are more reliable when the safe path is the easiest path. Require tenant context in repository queries. Make internal fields opt-in in serializers. Set conservative timeouts. Deny access unless a policy grants it. Keep development diagnostics away from production responses.
Good defaults also apply to sessions and credentials: short-lived tokens where practical, scoped service accounts, rotation procedures, explicit expiry, and revocation paths. A system should be able to remove access quickly without requiring a redesign during an incident.
6. Observability is a security control
A policy decision that cannot be investigated is difficult to trust. Audit events should make important actions reconstructable: who initiated an operation, which resource and tenant were involved, which policy path allowed it, and whether the action succeeded.
audit.info({
event: "project.read",
actorId: actor.userId,
tenantId: actor.tenantId,
projectId,
outcome: "allowed",
requestId,
});
Logs must be useful without becoming a second data leak. Avoid raw credentials, authentication headers, full payment details, and sensitive message bodies. Apply retention controls, restrict access to audit stores, and alert on meaningful patterns such as repeated authorization denials or unusual bursts of privileged actions.
7. Test defenses in authorized environments
Security testing belongs in environments where you have permission: local labs, CTFs, intentionally vulnerable training applications, staging systems, or programs with an explicit scope and safe-harbor policy. The goal is to validate defenses without creating risk for other people’s data or services.
For product code, turn lessons into repeatable tests. Add authorization tests for cross-tenant access, property tests for parsers, integration tests for output encoding and serialization, and regression tests for issues already fixed. Treat a discovered weakness as an opportunity to improve a class of failures, not merely patch one endpoint.
If you find a vulnerability in someone else’s system, follow the program’s reporting policy or use coordinated disclosure. Share the minimum evidence needed for reproduction, protect sensitive data, and give maintainers time to investigate and remediate.
Engineering checklist
- Document data provenance and trust boundaries for new flows.
- Validate at ingress; encode or parameterize for each output sink.
- Scope every object lookup to the authenticated actor, tenant, and action.
- Expose narrow capabilities instead of ambient administrative access.
- Threat-model irreversible or high-value workflows before implementation.
- Use deny-by-default policies, short-lived credentials, and explicit revocation.
- Log security-relevant decisions without logging secrets or unnecessary personal data.
- Test authorization boundaries and regressions in authorized environments only.
Conclusion
Authorized offensive-security practice is valuable because it makes system design assumptions visible. It teaches that storage is not trust, identifiers are not authorization, and a working happy path is not the same as a safe system.
The best outcome is not becoming suspicious of every line of code. It is building systems where boundaries, permissions, and recovery paths are explicit enough that ordinary engineering work stays secure by default.

