Deep dive · application security
Application security deep dive: OWASP Top 10, STRIDE, and secure SDLC for backend engineers.
JWT and RBAC solve authentication and authorization for one system. This guide covers the broader discipline interviewers probe independently of any specific auth mechanism — injection, broken access control, insecure deserialization, and SSRF with Java/Spring examples; STRIDE threat modeling walked through on a real transfer endpoint; a secure SDLC pipeline; secrets management; and dependency/SBOM hygiene.
Why application security is its own interview topic
A JWT filter and a role check answer one narrow question: is this caller who they claim to be, and are they allowed to hit this endpoint at all. Application security is the much broader discipline that decides whether the code behind that endpoint can be tricked into doing something it was never supposed to do — running a query it didn't intend to run, deserializing bytes it shouldn't trust, fetching a URL an attacker chose instead of the user. Interviewers ask about OWASP categories, threat modeling, and secure SDLC placement specifically because they're generalist signals: they work regardless of whether a candidate's last project used JWT, sessions, OAuth, or something else entirely, and they separate someone who wrote code that happens to work from someone who reasons about what an attacker would try differently.
OWASP Top 10 at a glance
The current (2021) OWASP Top 10 is a ranked list of the application-security risk categories that show up most often in real audits. Not every category gets equal depth below — the ones most distinctive to Java/Spring backends (injection, access control, cryptographic failures, deserialization, SSRF) get a full treatment; the rest get a tight, concrete summary.
| Rank | Category | Where it typically shows up in a Java/Spring service |
|---|---|---|
| A01 | Broken Access Control | An endpoint checks authentication but not resource ownership — the IDOR pattern. |
| A02 | Cryptographic Failures | Fast hashes for passwords, hand-rolled encryption, secrets in plaintext at rest. |
| A03 | Injection | String-concatenated SQL/JPQL, unvalidated NoSQL query operators, OS command building. |
| A04 | Insecure Design | A missing threat model — the vulnerability was never going to be caught by testing because the design itself had no control for it. |
| A05 | Security Misconfiguration | Actuator endpoints exposed publicly, verbose error pages, default credentials left in place. |
| A06 | Vulnerable and Outdated Components | A transitive dependency with a known CVE nobody on the team explicitly chose. |
| A07 | Identification and Authentication Failures | No rate limiting on login, tokens that never expire, weak session fixation handling. |
| A08 | Software and Data Integrity Failures | Insecure deserialization lives here in the 2021 list — unsigned artifacts, unverified CI plugins. |
| A09 | Security Logging and Monitoring Failures | An auth bypass or a data exfiltration that ran for months with nothing alerting on it. |
| A10 | Server-Side Request Forgery (SSRF) | A "fetch this URL" feature lets the server reach internal IPs or the cloud metadata endpoint. |
Jump to a section
Injection: SQL, NoSQL, and why parameterization actually works
Injection happens whenever attacker-controlled data is interpreted as part of a command's structure instead of purely as its data. SQL injection is the canonical case: if a query is built by concatenating a string, a crafted value can close a quote early and append new clauses, turning a lookup into an arbitrary read, write, or authentication bypass.
// Vulnerable -- string-concatenated query; user input becomes part of the SQL grammar
String sql = "SELECT * FROM users WHERE email = '" + email + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql);
// email = "x' OR '1'='1" turns the WHERE clause into an always-true condition
// Fixed -- Spring Data derived query, or an explicit JPQL query with a named parameter
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); // parameter bound, never concatenated
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmailExplicit(@Param("email") String email);
}
JPA and Spring Data prevent this by construction, not by convention: a derived query method or a @Query with a named parameter sends the SQL text and the parameter value to the driver as two separate channels. The driver binds the value as a literal of a fixed type at execution time, so no matter what characters the value contains, the database can never reinterpret it as SQL syntax. Escaping quotes by hand is the fragile alternative — it requires remembering every dangerous character for every specific database dialect, and it's exactly one forgotten case away from a bypass.
NoSQL injection is the same idea with a different mechanism. MongoDB queries are JSON documents, and if a client-supplied field is deserialized straight into a query object instead of a typed DTO, a value like {"$gt": ""} is interpreted as a query operator instead of a literal string, potentially matching every document in the collection. The fix mirrors the SQL case: bind incoming fields to a strongly typed request DTO with Bean Validation constraints first, so a field declared as String can never be reinterpreted as an operator object.
ORDER BY or table/column name from user input can't be parameterized the normal way, since identifiers aren't values — the fix there is a strict allow-list of known-safe column names, never direct interpolation of user input into that position.Broken access control: the IDOR pattern
This is OWASP's #1-ranked category, and for good reason: it's the easiest class of bug to introduce and the hardest to catch with automated tooling, because the code runs successfully and returns a plausible-looking response — it's just the wrong response for who's asking.
// Vulnerable -- checks that the caller is logged in, never that the order is theirs
@GetMapping("/orders/{id}")
public OrderDto getOrder(@PathVariable Long id) {
return orderService.findById(id); // any authenticated customer can read ANY order
}
// Fixed -- ownership is checked before the data is ever returned
@GetMapping("/orders/{id}")
public OrderDto getOrder(@PathVariable Long id, @AuthenticationPrincipal UserPrincipal caller) {
Order order = orderService.findById(id);
boolean owns = order.getUserId().equals(caller.getId());
if (!owns && !caller.hasRole("ADMIN")) {
throw new NotFoundException(); // 404, not 403 -- don't confirm the id even exists
}
return OrderDto.from(order);
}
This is an Insecure Direct Object Reference (IDOR): the endpoint takes an identifier straight from the URL and uses it to fetch a record without checking that the identifier belongs to the caller. It's dangerous precisely because it's invisible in normal testing — a developer testing as themselves only ever requests their own orders, so the bug never surfaces until someone deliberately tries a different id, which is exactly what an attacker does first. The fix is cheap once you know to look for it: either scope the repository query itself to the caller (findByIdAndUserId), or fetch the record and explicitly compare its owner to the caller before returning anything.
Cryptographic failures
This category covers everything from weak password hashing to home-grown encryption to secrets sitting in plaintext where a breach of any one system exposes them directly. The common thread is using cryptography incorrectly rather than not at all — teams usually do encrypt something, they just pick the wrong primitive or the wrong mode for the job.
java.util.Random is predictable from its seed; use SecureRandom for tokens, reset codes, and session identifiers.PasswordEncoder encoder = new BCryptPasswordEncoder(12); for storage, SecureRandom.getInstanceStrong() for anything unpredictability-sensitive, and encryption keys fetched from a KMS at runtime rather than committed alongside the data they protect.Insecure deserialization: a classic Java-specific vulnerability class
In the 2017 OWASP Top 10 this was its own category (A8: Insecure Deserialization); the 2021 revision folds it into the broader Software and Data Integrity Failures category, but it's common and distinctive enough as a Java problem specifically that it's worth understanding on its own.
ObjectInputStream.readObject() doesn't just parse bytes into data — it reconstructs live objects, invoking constructors, readObject, and readResolve methods as part of rebuilding the object graph. If the byte stream comes from an untrusted source, the attacker isn't limited to whatever fields your own classes expose: they can chain together "gadget" classes already sitting on the classpath (the Apache Commons Collections gadget chain is the famous example) into a sequence of side effects that ends in arbitrary code execution, without exploiting any bug you wrote yourself. The vulnerability is in the mechanism, not in any specific application logic.
// Vulnerable -- deserializing an untrusted byte stream with native Java serialization
try (ObjectInputStream in = new ObjectInputStream(request.getInputStream())) {
Object payload = in.readObject(); // gadget-chain code can run before your code ever sees "payload"
}
The most reliable fix is architectural: don't deserialize untrusted input with native Java serialization at all. Use a data-only format like JSON via Jackson, bound to explicit DTO classes with polymorphic typing disabled, so the deserializer can only ever populate known fields on known, inert classes — there's no mechanism for it to instantiate an arbitrary gadget class, because the target type is fixed by the code, not chosen by the input.
// Mitigated -- if native serialization can't be avoided, allow-list which classes may ever be built
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.jiquest.model.*;java.util.*;!*"); // allow known classes, reject everything else by default
in.setObjectInputFilter(filter); // JEP 290 -- rejects disallowed classes before construction starts
ObjectInputFilter is a deny-by-default allow-list checked against the class being constructed, before any of its code runs — it's a safety net for cases where native serialization is unavoidable (legacy protocols, RMI), not a reason to treat native deserialization of untrusted data as generally safe. The default posture should still be: don't do it.SSRF: when the server becomes the attacker's proxy
Server-Side Request Forgery happens whenever the application makes an outbound request to a destination that's fully or partially controlled by the caller — a "fetch this image," "validate this webhook URL," or "import from this link" feature. The danger is that the request originates from inside your network, with your server's credentials and network access, not the attacker's.
// Vulnerable -- fetches whatever URL the client supplies, no destination check at all
byte[] image = restTemplate.getForObject(request.getImageUrl(), byte[].class);
// an attacker-supplied URL of http://169.254.169.254/latest/meta-data/iam/security-credentials/
// can leak the instance's IAM role credentials on AWS
// Fixed -- resolve and validate the destination before the request is made, and re-check on redirect
URI target = URI.create(request.getImageUrl());
InetAddress resolved = InetAddress.getByName(target.getHost());
boolean privateRange = resolved.isLoopbackAddress() || resolved.isLinkLocalAddress() || resolved.isSiteLocalAddress();
if (privateRange || !allowedHosts.contains(target.getHost())) {
throw new BadRequestException("destination not allowed");
}
byte[] image = restTemplate.getForObject(target, byte[].class); // redirects disabled by client config
Two details separate a real fix from a false sense of safety. First, validating the hostname string isn't enough — you have to resolve it and check the actual IP, because a validated public hostname can still point at (or later be changed via DNS to point at) an internal address, a technique called DNS rebinding. Second, an HTTP client that automatically follows redirects can be handed a public URL that 302s to an internal one, silently defeating a check that only ran against the original address; redirects should either be disabled or re-validated at every hop.
More OWASP Top 10 categories worth knowing
These don't need a dedicated code example each to be interview-relevant — knowing the concrete failure mode in a Spring context is usually what's being tested.
/env or /heapdump exposed publicly, verbose Whitelabel error pages left on in production, default credentials never rotated./login, session tokens with no expiry, password reset tokens that don't invalidate after use.OWASP category → defense mapping
No single control fixes every category — the point of walking through the Top 10 individually is that each one has a specific, different mechanism that actually closes it. This map is the condensed version of everything above, side by side.
STRIDE threat modeling, applied to a real endpoint
Threat modeling is the practice of systematically asking "what could go wrong here" about a design before it's built, rather than discovering the answer later via a penetration test or, worse, an incident. STRIDE is Microsoft's mnemonic for six threat categories to check against every trust boundary in a data flow: Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, and Elevation of privilege. It's not a checklist you fill in once — it's a lens you apply to each component and each hop in a request flow, asking the same six questions every time.
Applying it works best against a concrete example rather than the abstract system: take the internal transfer endpoint from a wallet service like the one in this site's payment mini project — POST /internal/transfers, called only by an upstream Payment Service, which debits one wallet and credits another as a single local transaction.
Each threat above resolves to a concrete control: service-to-service authentication so the internal endpoint can't be called by anything except Payment Service (closes spoofing and elevation of privilege at once, since they're really the same trust boundary viewed two ways); request signing or mTLS so tampering in transit is detectable (tampering); an append-only ledger table alongside the mutable balance so every transfer has a permanent, attributable record (repudiation); a generic error response with internal detail only in server-side logs (information disclosure); and lock timeouts plus per-caller rate limiting on the transfer endpoint (denial of service). None of these controls are exotic — the value STRIDE adds isn't the fixes, which are mostly standard, it's making sure all six questions actually get asked against every hop instead of stopping once the obvious one (spoofing, usually) is handled.
Secure SDLC: a pipeline, not a checklist
Bolting a single "security review" step before release catches what's left over after everything upstream already missed it, and catches it at the most expensive possible point — after the feature is otherwise done. A secure SDLC instead places a specific activity at the stage where it's cheapest to fix what it finds: design-time threat modeling on paper, static analysis at commit time, dependency and secrets scanning at build time, dynamic testing against a running staging environment, and monitoring once it's live.
| Stage | Activity | Representative tool |
|---|---|---|
| Design | Threat modeling against the data flow diagram before code exists. | STRIDE, whiteboard or a tool like OWASP Threat Dragon. |
| Code | Static analysis on every pull request; secrets scanning before commit. | Semgrep, SpotBugs, SonarQube; gitleaks/truffleHog. |
| Build | Software composition analysis of every dependency; generate an SBOM. | OWASP Dependency-Check, Snyk; CycloneDX/SPDX. |
| Test | Dynamic testing against a running staging deployment. | OWASP ZAP, Burp Suite. |
| Release | Manual or scheduled penetration testing before high-risk releases; signed artifacts. | In-house or third-party pen test; artifact signing (Sigstore/cosign). |
| Monitor | Runtime logging and alerting on authz denials and anomalous access patterns. | Centralized logging, SIEM alert rules. |
# .github/workflows/security.yml (excerpt) -- separate gates, each blocking at the stage it belongs to
jobs:
sast:
steps: [ run: semgrep --config=auto . ] # every pull request
secrets-scan:
steps: [ run: gitleaks detect --source=. ] # every pull request
dependency-scan:
steps: [ run: mvn org.owasp:dependency-check-maven:check ] # every build, generates the SBOM too
dast:
needs: [deploy-staging]
steps: [ run: zap-baseline.py -t https://staging.internal ] # after deploy to staging, before prod
Secrets management: beyond environment variables
Hardcoding a database password in source control is an obvious mistake everyone avoids by now; moving it into an environment variable feels like the fix, and it's a real improvement, but it's not sufficient once a system has more than a handful of services.
# application.yml -- vulnerable: a secret checked into source control, visible to anyone with repo access
spring:
datasource:
password: Sup3rSecretProd123
An environment variable is still a single, static, long-lived value, and that's the actual problem, not where it happens to be stored. The same value gets copied into every container definition, every CI job that needs it, and it shows up in crash dumps and in CI logs the moment someone adds a debug step that prints the environment. Rotating it means finding and redeploying every service holding a copy, which in practice happens rarely, so a leaked value tends to stay valid for a long time. And because it's usually one shared value per environment rather than one per service instance, a single leak exposes everything that value can access, not just the one component that leaked it.
// Fixed -- a short-lived credential fetched at startup, never stored in source or a static env var
@Bean
DataSource dataSource(SecretsManagerClient secretsClient) {
GetSecretValueResponse secret = secretsClient.getSecretValue(r -> r.secretId("prod/order-db"));
DbCredential cred = objectMapper.readValue(secret.secretString(), DbCredential.class);
return DataSourceBuilder.create().username(cred.username()).password(cred.password()).build();
}
A secrets manager like HashiCorp Vault or AWS Secrets Manager changes the shape of the problem rather than just relocating the value: credentials can be generated dynamically per consumer and expire automatically in minutes, so a leaked credential has a narrow window of usefulness instead of an indefinite one. Rotation becomes automatic instead of a manual, easy-to-forget task, and every access is logged, so "who fetched this secret and when" is an audit query instead of an unanswerable question. The cost is real complexity — every service now needs logic to fetch and refresh a credential instead of reading one static value — which is exactly why teams typically adopt this first for the highest-value secrets (database and payment-provider credentials) and expand from there rather than migrating everything on day one.
Dependency vulnerability scanning and SBOM
Your own code being free of the vulnerabilities above isn't the same as your application being secure — a typical Spring Boot service pulls in dozens of direct dependencies and hundreds of transitive ones nobody on the team explicitly chose or even necessarily knows are there.
Log4Shell is the reference example: a remote-code-execution vulnerability in Log4j, a logging library present transitively in an enormous fraction of Java applications worldwide, most of which had never directly listed it as a dependency — it arrived through some other library's own dependency tree. When a disclosure like that happens, the question every team has to answer immediately is "are we affected, and where," and without automated tracking that means someone manually running a dependency tree across every repository and hoping nothing is missed.
Software composition analysis (SCA) tools address the day-to-day version of this by scanning declared and transitive dependencies against a vulnerability database on every build, failing the build (or at least raising a flag) when a known-vulnerable version is pulled in. A Software Bill of Materials (SBOM) is the artifact that makes the emergency version of the same question fast: a machine-readable, standardized inventory (CycloneDX or SPDX format) of every component and exact version that actually shipped in a given build, generated automatically rather than reconstructed after the fact. When the next Log4Shell-shaped disclosure happens, "are we affected" becomes a search across existing SBOMs instead of a scramble.
Input validation and output encoding: complementary, not interchangeable
These get conflated constantly, but they answer different questions at different points in a request's life, and skipping either one leaves a real gap the other can't cover.
public record CreateCustomerRequest(
@NotBlank @Size(max = 120) String companyName, // structural validation, right at the boundary
@Email String contactEmail
) {}
<!-- Output encoding at the point of use -- context-specific, not applied at input time -->
<span th:text="${customer.companyName}"></span> <!-- Thymeleaf auto-HTML-escapes th:text by default -->
// or explicitly, when a string is built by hand instead of through a templating engine:
String safe = Encode.forHtml(customer.getCompanyName()); // OWASP Java Encoder, HTML context
Input validation runs once, at the moment data enters the system: is this the right type, within the right length and range, in the right format. Its job is to reject malformed or wildly out-of-range data early and cheaply, before it reaches any business logic. Output encoding runs later and can run many times for the same piece of data, once for every different place it's written to: into an HTML page, into a SQL string (where parameterization is really output encoding for the query-syntax context), into a shell command, into a log line, into a JSON response. Each of those destinations has its own syntax and its own dangerous characters, so encoding has to be specific to the sink it's writing into, not generic.
The reason one can't substitute for the other is that legitimately valid input can still be dangerous output. A company name like O'Brien & Sons should pass validation without complaint — apostrophes and ampersands are normal in real names, and rejecting them just breaks the product for real customers. But if that exact string is written into an HTML page without encoding, the ampersand can start an unintended character reference and, combined with other unescaped input elsewhere on the page, contribute to a stored XSS vulnerability. Validation correctly let the value through because it was structurally fine; encoding is the separate step that neutralizes it for the specific place it ends up.
Key design decisions and interview talking points
These are the questions most likely to come up when an interviewer wants to know whether application security is something a candidate actually reasons about, or just a list of terms they can recite.
Why do parameterized queries (JPA/PreparedStatement) prevent SQL injection when string concatenation doesn't?
String concatenation lets attacker-supplied bytes become part of the SQL grammar itself — a value like ' OR '1'='1 changes the structure of the query, not just its data. A parameterized query sends the SQL text and the parameter values to the database as two separate channels; the driver binds the value as a literal of a fixed type, so no matter what characters it contains, it can never be reinterpreted as SQL syntax. This is why "just escape the quotes" is a weaker, error-prone substitute for parameterization — there is always another encoding you forgot.
What's an example of an endpoint that's authenticated but still vulnerable to IDOR, and how do you fix it?
GET /orders/{id} that checks isAuthenticated() but never checks that the order belongs to the caller lets any logged-in customer view any other customer's order just by incrementing the id. The fix is to scope the query itself to the caller (findByIdAndUserId(id, callerId)) or explicitly compare order.getUserId() to the caller's id, with an ADMIN bypass, before returning data — and to return 404 rather than 403 for someone else's resource so the endpoint doesn't even confirm the id exists.
Why is BCrypt or Argon2 preferred over SHA-256 for password hashing?
SHA-256 is a fast, general-purpose hash designed to verify integrity quickly, exactly the wrong property for password storage — a fast hash lets an attacker with a stolen hash dump try billions of guesses a second on commodity GPUs. BCrypt and Argon2 are deliberately slow and tunable (a cost factor you can raise as hardware gets faster), and Argon2 additionally makes large amounts of memory a required part of computing the hash, which specifically blunts GPU and ASIC cracking rigs that are memory-constrained relative to raw compute.
Why is deserializing untrusted data with native Java serialization dangerous, and what's the safer alternative?
ObjectInputStream.readObject() doesn't just reconstruct data, it reconstructs objects, running constructors and readObject/readResolve methods as it goes; an attacker who controls the byte stream can chain together gadget classes already present on the classpath into a sequence that executes arbitrary code, without any bug in your own code at all. The safer approach for untrusted input is to not use native Java serialization at all — use JSON via Jackson with explicit DTOs and polymorphic typing disabled. If Java serialization is unavoidable, an ObjectInputFilter (JEP 290) can allow-list the exact classes permitted to deserialize, rejecting everything else before construction even starts.
How would you prevent SSRF in a "fetch image from URL" feature?
Validate more than syntax — resolve the hostname and reject anything that resolves to a private, loopback, or link-local range (including the cloud metadata endpoint attackers specifically target for IAM credentials), and re-check after any redirect rather than validating only the original URL. Pair that with an explicit allow-list of permitted destination domains where the use case allows it, and route the outbound fetch through an egress proxy that enforces the same rules server-side as a second layer, since application-level checks alone are one missed code path away from being bypassed.
What's the difference between authentication failures and broken access control in OWASP terms?
Authentication failures are about proving who you are going wrong — weak password policies, session tokens that don't expire, credential stuffing with no rate limiting. Broken access control assumes authentication succeeded correctly and asks a different question: now that we know who you are, should you be allowed to do this specific thing to this specific resource. An endpoint can have flawless authentication and still be completely broken on access control — the system correctly knows you're customer 42, it just never checks whether order 99 belongs to customer 42.
Why isn't storing secrets in environment variables sufficient at scale?
Environment variables are a real improvement over hardcoding a secret in source, but they're still a static, long-lived value copied everywhere the process runs — into container definitions, CI logs that print env for debugging, crash dumps, and any process that can read another process's environment on the same host. Rotating one means redeploying every service that holds a copy, which in practice means teams rotate rarely, so a leaked value stays valid for months. At scale the real requirements are automatic rotation, per-service scoping, and an audit trail of exactly which service fetched which secret and when — none of which a plain env var gives you.
How does a secrets manager with short-lived credentials reduce blast radius compared to static API keys?
A static database password that leaks is valid until someone notices and manually rotates it, potentially indefinitely. A secrets manager like Vault or AWS Secrets Manager can instead issue a credential that's dynamically generated per request and expires in minutes, so even a successfully exfiltrated credential is only useful for a narrow window, and each service instance gets its own credential rather than everyone sharing one, so a compromised instance only exposes its own access, not the whole fleet's. The trade-off is added complexity, which is why this is usually adopted for the highest-value secrets first — database and payment-provider credentials — rather than everything at once.
Why do you need an SBOM if your own code has no vulnerabilities?
A typical Spring Boot service pulls in dozens of direct dependencies and hundreds of transitive ones you never explicitly chose — Log4Shell was exactly this shape of problem, a vulnerability three or four levels deep in a logging library nobody on the team had directly added. Without a software bill of materials, answering "are we affected?" during a live disclosure means someone manually running dependency trees across every service and hoping they didn't miss one. An SBOM is a queryable inventory of every component and version actually shipped, so the same question becomes a search instead of an afternoon of archaeology across every repo.
What's the difference between SAST and DAST, and where does each belong in the pipeline?
SAST (static analysis) reads source code without running it, catching patterns like string-concatenated SQL or a missing authorization check before the code is ever deployed — it belongs in CI, on every pull request, because it's fast and gives a developer feedback in minutes. DAST (dynamic analysis) attacks a running instance of the application from the outside, the way a real attacker would, catching issues SAST structurally can't see — misconfigured headers, an endpoint reachable when it shouldn't be, actual exploitability rather than a suspicious pattern. DAST needs a deployed environment and takes longer, so it runs later, typically against staging before release, not on every commit.
Walk through applying STRIDE to the wallet service's transfer endpoint — what threats do you find?
Spoofing: can the caller's identity on the internal POST /internal/transfers call be forged, given it's meant to be called only by Payment Service? Tampering: is the request body protected from modification in transit, or does it rely only on network trust? Repudiation: if a transfer is disputed later, is there an immutable audit trail of who initiated it, separate from the mutable balance itself? Information disclosure: does a failed transfer leak internal details in its error response? Denial of service: can the endpoint be flooded with transfer attempts against the same wallet to hold its row lock and starve legitimate transfers? Elevation of privilege: if the internal endpoint were reachable directly, could a caller move money between wallets it doesn't own? Each answer points at a specific control — service-to-service auth, request signing, an append-only ledger, generic error messages, lock timeouts, and network-level restriction — which is the actual output STRIDE is for.
Why are input validation and output encoding both necessary — isn't one enough?
They defend against different things at different points. Input validation checks that data has the shape you expect the moment it enters the system and rejects what doesn't. Output encoding is applied later, at the specific point data is written into a specific destination, transforming characters that are dangerous in that destination's syntax. A perfectly valid input can still be dangerous output: a company name like O'Brien & Sons is legitimate and should pass validation, but if it's written into an HTML page without encoding, the ampersand and apostrophe can still break the page's structure. Validation narrows what data is allowed to exist; encoding neutralizes it for wherever it's about to be written.
Why should authorization checks live in the service layer instead of only in the controller?
A controller-level authorization annotation is easy to forget on the next endpoint a different developer adds six months later, and easy to accidentally bypass if the same service method later gets called from a second, internal code path that skips the controller entirely — a scheduled job, an event listener, a new admin tool. Putting the ownership and role check inside the service method itself means every caller, present and future, goes through the same gate, instead of the gate being a per-endpoint decision someone has to remember to reapply.
What's a real-world example of insecure deserialization causing a major breach?
The Apache Commons Collections gadget chain, disclosed in 2015, is the canonical one — a library present on the classpath of huge numbers of Java applications could be chained through readObject calls to achieve remote code execution anywhere an application deserialized untrusted bytes with native Java serialization, even though Commons Collections itself has nothing to do with security and nobody had written an exploit-specific bug — the vulnerability was in the combination, not any one library. It's the reason "don't deserialize untrusted data with ObjectInputStream" became a blanket rule rather than a case-by-case judgment call.
How do you prevent NoSQL injection in a MongoDB-backed Spring Data application?
The classic NoSQL injection isn't a syntax-escaping problem the way SQL injection is — it's an operator injection problem, where a client sends a JSON body with an operator like $gt for a field expected to be a plain string, and if that value is deserialized straight into a query without a strict DTO, it's interpreted as a query operator instead of a literal, potentially matching every document. The fix is the same instinct as parameterized SQL applied to a different mechanism: bind incoming request fields to a typed DTO with Bean Validation constraints first, never pass a raw, attacker-controlled JSON map straight into a query, so a field that should be a String can never be reinterpreted as a query operator.
Why doesn't TLS termination at a load balancer eliminate the need for HTTPS between internal services?
Terminating TLS at the edge secures the leg the public internet can see, but traffic between the load balancer and internal services, and between internal services themselves, often then travels in plaintext inside the "trusted" network — which is only trusted until it isn't, whether through a misconfiguration, a compromised container, or an insider with network access. Encrypting internal traffic too, via a service mesh or TLS on internal listeners, is the same defense-in-depth argument as verifying a JWT in every service instead of only at the gateway.
What's the risk of returning a raw exception stack trace to the client on error?
A stack trace reveals internal structure an attacker would otherwise have to guess at for free — class and package names, the ORM and its version, sometimes a fragment of a SQL query or a file path, all of which narrows down exactly which known vulnerabilities to try next. The fix is a global exception handler (@ControllerAdvice) that logs the full detail server-side for debugging and returns a generic, safe message and a correlation id to the client, so a developer can still find the real error in the logs without ever putting it on the wire.
How would you decide whether to patch a vulnerable dependency yourself versus wait for an upstream fix?
Start from actual exploitability in your context, not just the CVE's severity score — a critical-rated bug in a class your application never instantiates is lower real risk than a medium-rated one in a code path that handles unauthenticated user input directly. If a fixed version already exists, bumping it is almost always faster and safer than a local patch, since you inherit the maintainer's fix and their future updates. If no fix exists yet and the risk is live, a temporary compensating control — disabling the vulnerable feature, filtering the specific input pattern at a gateway layer — buys time without forking a dependency you'd then be responsible for maintaining yourself.
What's the single most common mistake in how developers implement access control checks?
Checking that a user is authenticated and stopping there, treating "logged in" as equivalent to "authorized for this specific resource" — which is exactly the IDOR pattern, and it's common because authentication is the part every framework sets up for you by default, while resource-level ownership checks have to be written deliberately, every time, for every endpoint that takes an id. The mistake compounds when authorization logic is duplicated across many controller methods instead of centralized, because then fixing it means finding and fixing every instance instead of one shared gate.
Related guides
Update these hrefs to your published Blogger post URLs once each page is live.
Post a Comment
Add