Repository: stephenberry/glaze (2026-07-30T13:46:13Z)

Issue:
BEVE reader has no recursion depth limit: a small hostile buffer overflows the stack
## Summary

`include/glaze/beve/read.hpp` and `include/glaze/beve/skip.hpp` enforce no recursion depth limit, so a small hostile BEVE buffer can overflow the stack and crash the process. The limit already exists (`max_recursive_depth_limit = 256` in `core/context.hpp`) and is enforced by the JSON reader, the YAML reader, and — notably — the BEVE **to-JSON transcoder**. It is simply absent from the BEVE **reader**.

The same buffer is therefore safe to transcode and unsafe to deserialize:

```
levels=500000 buffer=1000006 bytes
  beve_to_json: error (guarded)
  read_beve:    SIGSEGV
```

## Reachable with default options

No opt-out of strictness is required. A variant with two or more object alternatives runs a pass-1 key scan that skips every member value unconditionally, and `skip_untyped_array_beve` recurses once per nesting level:

```c++
struct A { int a{}; };
struct B { int b{}; };
using V = std::variant<A, B>;

// object { "zz": [[[[ ... ]]]] } -- 2 bytes per nesting level
std::string craft(int levels) {
   std::string b;
   b.push_back(char(glz::tag::object));
   b.push_back(char(1 << 2));            // one key
   b.push_back(char(2 << 2));
   b += "zz";                            // a key belonging to no alternative
   for (int i = 0; i < levels; ++i) {
      b.push_back(char(glz::tag::generic_array));
      b.push_back(char(1 << 2));         // one element
   }
   b.push_back(char(glz::tag::null));
   return b;
}

V out{};
glz::read_beve(out, craft(150000));      // SIGSEGV -- no options changed
```

Measured on macOS/arm64 with the default 8 MB main-thread stack, `-O1`:

| target | options | result |
|---|---|---|
| `std::variant<A,B>` | **default** | 130k levels ok, **150k levels (≈300 KB) SIGSEGV** |
| plain struct | default (`error_on_unknown_keys = true`) | safe — the unknown key errors before it is skipped |
| plain struct | `error_on_unknown_keys = false` | **500k levels (≈1 MB) SIGSEGV** |
| any | `glz::beve_to_json` | errors cleanly at every size |

Two bytes per level means the attacker's cost is trivial. Platforms with smaller stacks are proportionally cheaper to crash: Windows gives the main thread 1 MB by default, roughly an eighth of the above, putting the variant path in the tens-of-kilobytes range.

## Where the guard is missing

```
include/glaze/json/read.hpp        4 references to max_recursive_depth_limit
include/glaze/yaml/read.hpp        1
include/glaze/beve/beve_to_json.hpp 2
include/glaze/beve/read.hpp        0     <--
include/glaze/beve/skip.hpp        0     <--
```

The recursing entry points that need it:

- `skip_value<BEVE>::op` — via `skip_object_beve` and `skip_untyped_array_beve`
- the BEVE object reader, the generic-array reader, and the variant reader in `beve/read.hpp`

`core/context.hpp` already provides an RAII `depth_guard` written for exactly this ("erroring out before the nesting reaches `max_recursive_depth_limit` so adversarial deeply nested input can't overflow the stack"), so the fix is to apply it rather than to design anything.

## Related

Pre-existing; not introduced by #2707. It surfaced twice while reviewing that PR:

- The unbounded skip loop in `skip_untyped_array_beve` was a separate DoS (a 14-byte buffer spinning for days) fixed in #2707. That fix stops the *iteration* count; this issue is about the *recursion* depth, which is untouched.
- A test in #2707 that read an 800-level nested variant segfaulted `beve_test` on clang-cl, because reading recurses once per level and Windows gives 1 MB. The test was capped at 200 levels to work around it — that workaround exists only because this limit is missing.

Reported with repros in the review of #2707.



Discussion:
@stephenberry Hey Stephen is this buffer overflow patched already in latest version?
@ElevationsRPG, discovered this yesterday and fixing it now.



Patch:
diff --git a/docs/binary.md b/docs/binary.md
index 6798c2dde0..c953a2b40c 100644
--- a/docs/binary.md
+++ b/docs/binary.md
@@ -29,6 +29,10 @@ if (!ec) {
 >
 > Reading binary is safe for invalid input and does not require null terminated buffers.
 
+**Recursion depth**
+
+BEVE spends as little as two bytes per nesting level, so a small hostile buffer could otherwise drive the reader deep enough to overflow the stack. The readers, the value skipper, and `glz::beve_to_json` all cap nesting at `max_recursive_depth_limit` (256 wire levels) and return `error_code::exceeded_max_recursive_depth` beyond it. Each object and each array counts as a level, so a struct holding a vector of itself reaches the cap at 128 struct levels.
+
 ## Calculate BEVE Size
 
 The `glz::beve_size` function calculates the exact number of bytes needed to serialize a value to BEVE format, without actually performing the serialization. This is useful when you need to pre-allocate a buffer of the exact size, such as when writing to shared memory for inter-process communication.
diff --git a/docs/cbor.md b/docs/cbor.md
index f10afe9d96..eb9b4eed72 100644
--- a/docs/cbor.md
+++ b/docs/cbor.md
@@ -30,6 +30,10 @@ if (!ec) {
 > [!NOTE]
 > Reading CBOR is safe for invalid input and does not require null-terminated buffers.
 
+**Recursion depth**
+
+A CBOR nesting level costs a single byte, so a small hostile buffer could otherwise drive the reader deep enough to overflow the stack. The readers and the value skipper cap nesting at `max_recursive_depth_limit` (256 levels) and return `error_code::exceeded_max_recursive_depth` beyond it. Every array and every map counts as a level, so a struct holding a vector of itself reaches the cap at 128 struct levels.
+
 ## Exceptions Interface
 
 If you prefer exceptions over error codes:
diff --git a/docs/msgpack.md b/docs/msgpack.md
index 3ee1321ee0..2169f3667e 100644
--- a/docs/msgpack.md
+++ b/docs/msgpack.md
@@ -150,6 +150,10 @@ std::chrono::milliseconds decoded{};
 glz::read_msgpack(decoded, buffer); // decoded.count() == 12345
 ```
 
+## Recursion Depth
+
+A `fixarray` or `fixmap` nesting level costs a single byte, so a small hostile buffer could otherwise drive the reader deep enough to overflow the stack. The readers and the value skipper cap nesting at `max_recursive_depth_limit` (256 levels) and return `error_code::exceeded_max_recursive_depth` beyond it. Structs are written as arrays, so a struct holding a vector of itself counts two levels per struct and reaches the cap at 128 struct levels.
+
 ## Binary Buffers
 
 Glaze automatically emits the compact MessagePack `bin*` tags for contiguous byte buffers such as
diff --git a/docs/security.md b/docs/security.md
index 9e7e89c894..0ed533e338 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -242,7 +242,7 @@ This allows building unified deserializers that scale security based on the trus
 
 When parsing JSON from untrusted sources:
 
-1. **Limit recursion depth**: Deeply nested structures can cause stack overflow. Consider flattening data structures or implementing depth limits at the application level.
+1. **Recursion depth is bounded**: the readers, skippers, and format converters cap nesting at `max_recursive_depth_limit` (256 levels) and return `error_code::exceeded_max_recursive_depth`, so deeply nested input cannot overflow the stack. Every object and every array counts as a level, so a struct holding a vector of itself reaches the cap at 128 struct levels. Flatten your data structures if you legitimately need to exceed that.
 
 2. **Limit string sizes**: Very large strings can consume excessive memory. Control this through input buffer size limits.
 
diff --git a/include/glaze/beve/lazy.hpp b/include/glaze/beve/lazy.hpp
index efdbb20fc1..3023abf8ff 100644
--- a/include/glaze/beve/lazy.hpp
+++ b/include/glaze/beve/lazy.hpp
@@ -29,7 +29,16 @@ namespace glz
 
    namespace detail
    {
-      // Skip a BEVE value and return the new position (no context needed for position tracking)
+      // Skip a BEVE value and return the new position (no context needed for position tracking).
+      //
+      // A failed skip leaves `it` part way through the value, and every caller here treats the
+      // returned pointer as the start of the next one, so handing that position back would resolve
+      // later lookups against the middle of a value. Return `end` instead: the caller's scan stops
+      // and reports nothing found rather than something wrong. Nesting deeper than
+      // max_recursive_depth_limit is the reachable case -- skip_value<BEVE> refuses it, which is what
+      // keeps this from being a stack overflow -- so a lazy view over such a value can no longer
+      // reach the members past it. Distinguishing "too deep" from "absent" needs an error channel
+      // through the lazy API, which these accessors do not have.
       template <opts Opts>
       GLZ_ALWAYS_INLINE const char* skip_value_beve_lazy(const char* p, const char* end) noexcept
       {
@@ -38,6 +47,9 @@ namespace glz
          context ctx{};
          auto it = p;
          skip_value<BEVE>::op<Opts>(ctx, it, end);
+         if (bool(ctx.error)) [[unlikely]] {
+            return end;
+         }
          return it;
       }
 
diff --git a/include/glaze/beve/read.hpp b/include/glaze/beve/read.hpp
index 94860b7727..57981d6f4c 100644
--- a/include/glaze/beve/read.hpp
+++ b/include/glaze/beve/read.hpp
@@ -18,6 +18,14 @@
 // If we know the first function called has an end check, we don't need a guard at the top of the function
 // Also, after almost every function call we need to check if an error was produced
 
+// Recursion depth: BEVE spends two bytes per nesting level, so input alone can drive the reader
+// arbitrarily deep and overflow the stack. Every reader that consumes an object or generic-array
+// header and then descends into the members takes one level with glz::depth_guard, and
+// skip_value<BEVE> does the same, which bounds the descent at max_recursive_depth_limit and reports
+// exceeded_max_recursive_depth instead of crashing. A reader that hands its header off to another
+// reader (a reflectable struct read positionally defers to the tuple reader) leaves the level to that
+// reader, so one wire level is counted once.
+
 namespace glz
 {
    template <>
@@ -625,16 +633,21 @@ namespace glz
       static constexpr auto tagging = variant_tagging_v<T>;
 
       // Try each alternative in declaration order, rewinding the iterator on failure. Returns true
-      // when an alternative parsed cleanly, leaving `value` holding it. `truncated` is set when any
-      // alternative ran off the end of the buffer, which says the input is incomplete rather than
-      // that it matched no alternative.
+      // when an alternative parsed cleanly, leaving `value` holding it. `input_error` records a
+      // failure that is a property of the input rather than of the alternative set -- a buffer that
+      // ran out or nesting past the depth limit -- which no other alternative could have done better
+      // with.
       template <auto Opts>
-      static bool try_each_pass(auto&& value, is_context auto&& ctx, auto&& it, auto end, bool& truncated) noexcept
+      static bool try_each_pass(auto&& value, is_context auto&& ctx, auto&& it, auto end,
+                                error_code& input_error) noexcept
       {
          bool matched = false;
+         bool exhausted = false;
          const auto start = it;
          for_each<variant_size>([&]<size_t I>() {
-            if (matched) {
+            if (matched || exhausted || input_error == error_code::exceeded_max_recursive_depth) {
+               // Nesting past the limit is a property of the input: no other alternative can read it,
+               // and re-parsing the subtree per alternative at every level is exponential.
                return;
             }
             it = start;
@@ -644,11 +657,19 @@ namespace glz
             ctx.error = error_code::none;
             ctx.custom_error_message = {}; // else a rejected alternative's message outlives its error
             parse<BEVE>::op<Opts>(std::get<I>(value), ctx, it, end);
+            // Charge only what a REJECTED attempt parsed. That is the wasted work the bound is
+            // about; charging the successful alternative too would bill every enclosing level for
+            // the same bytes and make a valid nest look exponential.
+            const bool budget_left = bool(ctx.error) ? charge_speculation(ctx, size_t(it - start)) : true;
             if (!bool(ctx.error)) {
                matched = true;
             }
-            else if (ctx.error == error_code::unexpected_end) {
-               truncated = true;
+            else if (ctx.error == error_code::unexpected_end || ctx.error == error_code::exceeded_max_recursive_depth) {
+               input_error = ctx.error;
+            }
+            if (!budget_left) {
+               // Out of speculation budget: keep this alternative's error and stop.
+               exhausted = true;
             }
          });
          if (!matched) {
@@ -670,19 +691,22 @@ namespace glz
       static void try_each(auto&& value, is_context auto&& ctx, auto&& it, auto end) noexcept
       {
          const auto start = it;
-         bool truncated = false;
+         error_code input_error{};
          if constexpr (check_allow_conversions(Opts)) {
-            if (try_each_pass<opt_false<Opts, allow_conversions_opt_tag{}>>(value, ctx, it, end, truncated)) {
+            if (try_each_pass<opt_false<Opts, allow_conversions_opt_tag{}>>(value, ctx, it, end, input_error)) {
                return;
             }
          }
-         if (try_each_pass<Opts>(value, ctx, it, end, truncated)) {
+         // A lenient retry cannot make over-nested input readable, and running it doubles the work at
+         // every level of a deep nest.
+         if (input_error != error_code::exceeded_max_recursive_depth &&
+             try_each_pass<Opts>(value, ctx, it, end, input_error)) {
             return;
          }
          it = start;
-         // An incomplete buffer is a property of the input, not of the alternative set, so report it
-         // as such instead of blaming variant resolution.
-         ctx.error = truncated ? error_code::unexpected_end : error_code::no_matching_variant_type;
+         // An incomplete or over-nested buffer is a property of the input, not of the alternative set,
+         // so report it as such instead of blaming variant resolution.
+         ctx.error = input_error != error_code::none ? input_error : error_code::no_matching_variant_type;
       }
 
       // Parse the object at `it` as alternative I, handling the merged discriminator key when the
@@ -749,12 +773,21 @@ namespace glz
          if (!bool(ctx.error)) {
             return true;
          }
-         if (!try_others || ctx.error == error_code::missing_key) {
+         // The missing_key exclusion is the caller's own strictness (see below). Nesting past the depth
+         // limit is excluded for a different reason: it is a property of the input rather than of the
+         // alternative, so retrying re-parses the whole subtree for nothing -- once per alternative,
+         // at every level of the nest, which is exponential in the depth.
+         if (!try_others || ctx.error == error_code::missing_key ||
+             ctx.error == error_code::exceeded_max_recursive_depth) {
             return false;
          }
 
          bool recovered = false;
+         bool exhausted = false;
          for_each<variant_size>([&]<size_t I>() {
+            if (exhausted) {
+               return;
+            }
             if constexpr (beve_variant_is_object_alt<std::variant_alternative_t<I, T>>) {
                using V = std::variant_alternative_t<I, T>;
                using X = std::conditional_t<is_memory_object<V>, memory_type<V>, V>;
@@ -765,7 +798,7 @@ namespace glz
                   return;
                }
                else {
-                  if (recovered || I == resolved) {
+                  if (recovered || exhausted || I == resolved) {
                      return;
                   }
                   it = obj_start;
@@ -776,6 +809,11 @@ namespace glz
                   }
                   parse_object_alt<AltOpts, tagged, I>(value, ctx, it, end);
                   recovered = !bool(ctx.error);
+                  if (!recovered && !charge_speculation(ctx, size_t(it - obj_start))) {
+                     // Out of budget: an ambiguous nest re-parses the same subtree per alternative at
+                     // every level, which is exponential. Keep this error and stop retrying.
+                     exhausted = true;
+                  }
                }
             }
          });
@@ -1046,8 +1084,10 @@ namespace glz
             }
 
             // The missing_key exclusion applies here too: the last resort must not answer a strict
-            // read with some other alternative that happens to tolerate the absent key.
-            if (not authoritative && ctx.error != error_code::missing_key) {
+            // read with some other alternative that happens to tolerate the absent key. Over-nested
+            // input is likewise not worth another parse of the whole subtree.
+            if (not authoritative && ctx.error != error_code::missing_key &&
+                ctx.error != error_code::exceeded_max_recursive_depth) {
                const auto first_error = ctx.error;
                const auto first_error_it = it;
                // Last resort: an alternative outside the recognized object categories (a custom
@@ -1243,6 +1283,14 @@ namespace glz
             ctx.error = error_code::syntax_error;
             return;
          }
+
+         // As with read_adjacent: the value of one positionally tagged variant may be another, and
+         // this reader consumes the [id, value] array itself rather than through a guarded reader.
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          ++it;
          const auto n = int_from_compressed(ctx, it, end);
          if (bool(ctx.error)) [[unlikely]] {
@@ -1575,17 +1623,33 @@ namespace glz
                return;
             }
 
+            depth_guard guard{ctx};
+            if (!guard) [[unlikely]] {
+               return;
+            }
+
             ++it;
             const auto n = int_from_compressed(ctx, it, end);
             if (bool(ctx.error)) [[unlikely]] {
                return;
             }
 
+            // Each element needs at least 1 byte for its tag, so a count larger than the remaining
+            // input cannot be honored. Without this (and the error check in the loop) an element count
+            // of 2^48 in a 3 byte buffer spins for hours on reads that error instantly.
+            if (uint64_t(end - it) < n) [[unlikely]] {
+               ctx.error = error_code::invalid_length;
+               return;
+            }
+
             value.clear();
 
             for (size_t i = 0; i < n; ++i) {
                V v;
                parse<BEVE>::op<Opts>(v, ctx, it, end);
+               if (bool(ctx.error)) [[unlikely]] {
+                  return;
+               }
                value.emplace(std::move(v));
             }
          }
@@ -2202,6 +2266,12 @@ namespace glz
                ctx.error = error_code::syntax_error;
                return;
             }
+
+            depth_guard guard{ctx};
+            if (!guard) [[unlikely]] {
+               return;
+            }
+
             ++it;
             std::conditional_t<check_partial_read(Opts), size_t, const size_t> n = int_from_compressed(ctx, it, end);
             if (bool(ctx.error)) [[unlikely]] {
@@ -2288,19 +2358,39 @@ namespace glz
             }
          }
 
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          ++it;
          const size_t n = int_from_compressed(ctx, it, end);
          if (bool(ctx.error)) [[unlikely]] {
             return;
          }
 
+         // A pair costs at least one byte of input, so a count exceeding what remains cannot be
+         // honored -- the same bound the map reader applies. Bailing here (and on error in the loop)
+         // keeps a bogus count from emplacing its way through memory on a read that errors on the
+         // first element.
+         if (n > size_t(end - it)) [[unlikely]] {
+            ctx.error = error_code::unexpected_end;
+            return;
+         }
+
          value.clear();
 
          constexpr uint8_t key_tag = beve_key_traits<Key>::key_tag;
          for (size_t i = 0; i < n; ++i) {
             auto& item = value.emplace_back();
             parse<BEVE>::op<no_header_on<Opts>()>(item.first, key_tag, ctx, it, end);
+            if (bool(ctx.error)) [[unlikely]] {
+               return;
+            }
             parse<BEVE>::op<Opts>(item.second, ctx, it, end);
+            if (bool(ctx.error)) [[unlikely]] {
+               return;
+            }
          }
       }
    };
@@ -2324,6 +2414,11 @@ namespace glz
             return;
          }
 
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          ++it;
          const auto n = int_from_compressed(ctx, it, end);
          if (bool(ctx.error)) [[unlikely]] {
@@ -2384,6 +2479,11 @@ namespace glz
             }
          }
 
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          ++it;
          std::conditional_t<Opts.partial_read, size_t, const size_t> n = int_from_compressed(ctx, it, end);
          if (bool(ctx.error)) [[unlikely]] {
@@ -2728,6 +2828,13 @@ namespace glz
                ctx.error = error_code::syntax_error;
                return;
             }
+
+            // The reflectable branch above delegates to the tuple reader, which takes the level there.
+            depth_guard guard{ctx};
+            if (!guard) [[unlikely]] {
+               return;
+            }
+
             ++it;
             using V = std::decay_t<T>;
             constexpr auto N = reflect<V>::size;
@@ -2774,6 +2881,11 @@ namespace glz
             return;
          }
 
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          ++it;
 
          static constexpr auto N = reflect<T>::size;
@@ -2944,6 +3056,12 @@ namespace glz
             ctx.error = error_code::syntax_error;
             return;
          }
+
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          ++it;
 
          constexpr auto N = reflect<T>::size;
@@ -2976,6 +3094,12 @@ namespace glz
             ctx.error = error_code::syntax_error;
             return;
          }
+
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          ++it;
 
          using V = std::decay_t<T>;
diff --git a/include/glaze/beve/skip.hpp b/include/glaze/beve/skip.hpp
index e5d64cb734..0a1659df40 100644
--- a/include/glaze/beve/skip.hpp
+++ b/include/glaze/beve/skip.hpp
@@ -4,6 +4,7 @@
 #pragma once
 
 #include "glaze/beve/header.hpp"
+#include "glaze/core/context.hpp"
 #include "glaze/core/opts.hpp"
 #include "glaze/core/read.hpp"
 #include "glaze/file/file_ops.hpp"
@@ -310,6 +311,16 @@ namespace glz
       if (invalid_end(ctx, it, end)) {
          return;
       }
+
+      // Every recursive skip path -- object members, generic array elements, the matrix and legacy
+      // type-tag extensions -- descends by calling back into here, so this one guard bounds them all.
+      // Two bytes of input buy a nesting level, so without it a tiny hostile buffer (a few hundred KB
+      // of nested empty arrays) overflows the stack before it runs out of tags to skip.
+      depth_guard guard{ctx};
+      if (!guard) [[unlikely]] {
+         return;
+      }
+
       switch (uint8_t(*it) & 0b00000'111) {
       case tag::null: {
          ++it;
diff --git a/include/glaze/cbor/read.hpp b/include/glaze/cbor/read.hpp
index 4f5732a9c0..3856d5dead 100644
--- a/include/glaze/cbor/read.hpp
+++ b/include/glaze/cbor/read.hpp
@@ -13,6 +13,12 @@
 #include "glaze/util/dump.hpp"
 #include "glaze/util/for_each.hpp"
 
+// Recursion depth: a CBOR nesting level costs a single byte, so input alone can drive the reader
+// arbitrarily deep and overflow the stack. Every reader that consumes an array or map head and then
+// descends into the items takes one level with glz::depth_guard, and skip_value<CBOR> does the same,
+// which bounds the descent at max_recursive_depth_limit and reports exceeded_max_recursive_depth
+// instead of crashing.
+
 namespace glz
 {
    namespace cbor_detail
@@ -1054,6 +1060,12 @@ namespace glz
             return;
          }
 
+         // The typed and complex array forms above are flat; only this branch descends into elements.
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          if (additional_info == info::indefinite) {
             // Indefinite-length array
             if constexpr (resizable<T>) {
@@ -1167,6 +1179,11 @@ namespace glz
             return;
          }
 
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          value.clear();
 
          if (additional_info == info::indefinite) {
@@ -1263,6 +1280,11 @@ namespace glz
             return;
          }
 
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          uint64_t count = cbor_detail::decode_arg(ctx, it, end, additional_info);
          if (bool(ctx.error)) [[unlikely]]
             return;
@@ -1310,6 +1332,11 @@ namespace glz
             return;
          }
 
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          static constexpr auto N = reflect<T>::size;
          if constexpr (N == 0) {
             (void)value;
@@ -1461,6 +1488,11 @@ namespace glz
             return;
          }
 
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          using V = std::decay_t<T>;
          static constexpr auto N = glz::tuple_size_v<V>;
 
@@ -1521,6 +1553,11 @@ namespace glz
             }
          }
 
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          for_each<reflect<T>::size>(
             [&]<size_t I>() { parse<CBOR>::op<Opts>(get_member(value, get<I>(reflect<T>::values)), ctx, it, end); });
       }
@@ -1743,6 +1780,12 @@ namespace glz
             return;
          }
 
+         // This reader consumes the [index, value] array itself, so the level is its to count.
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          uint64_t count = cbor_detail::decode_arg(ctx, it, end, additional_info);
          if (bool(ctx.error)) [[unlikely]]
             return;
diff --git a/include/glaze/cbor/skip.hpp b/include/glaze/cbor/skip.hpp
index 3966926ecc..c86c6a99e5 100644
--- a/include/glaze/cbor/skip.hpp
+++ b/include/glaze/cbor/skip.hpp
@@ -92,6 +92,14 @@ namespace glz
             return;
          }
 
+         // Arrays, maps, and tags all skip their children by calling back into here, so this one
+         // guard bounds every recursive skip path. A CBOR nesting level costs a single byte, so
+         // without it a tiny hostile buffer overflows the stack before it runs out of items to skip.
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          uint8_t initial;
          std::memcpy(&initial, it, 1);
          ++it;
diff --git a/include/glaze/core/context.hpp b/include/glaze/core/context.hpp
index a78103ab76..3b3e454cee 100644
--- a/include/glaze/core/context.hpp
+++ b/include/glaze/core/context.hpp
@@ -8,6 +8,8 @@
 #include <string>
 #include <string_view>
 
+#include "glaze/util/inline.hpp"
+
 namespace glz
 {
    inline constexpr size_t max_recursive_depth_limit = 256;
@@ -117,6 +119,8 @@ namespace glz
       error_code error{};
       std::string_view custom_error_message;
       // INTERNAL USE:
+      size_t speculation_budget{}; // Bytes of speculative (variant alternative) re-parsing left;
+      // seeded per read from the input size, 0 means unlimited. See charge_speculation below.
       uint32_t depth{}; // Nesting depth of structures (objects/arrays)
       // Used for indentation when writing and for stack overflow protection when reading
       std::string current_file; // top level file path
@@ -136,6 +140,11 @@ namespace glz
    // erroring out before the nesting reaches max_recursive_depth_limit so adversarial deeply
    // nested input can't overflow the stack. Mirrors the per-reader guards already used by the
    // BSON and JSONB binary readers; placed here so the text-format readers can share one copy.
+   //
+   // The JSON/NDJSON readers cannot use this: with a non-null-terminated buffer they overload
+   // ctx.depth as a completion counter (a value that closed cleanly ends at depth 0, which is how
+   // finalize_read_context tells "the buffer ended exactly here" from "the buffer was truncated"),
+   // so they count by hand and enforce the same limit inline.
    template <class Ctx>
    struct depth_guard
    {
@@ -158,6 +167,75 @@ namespace glz
       explicit operator bool() const noexcept { return entered; }
    };
 
+   // A variant read is speculative: an alternative is parsed to find out whether it fits, and a
+   // rejected one is rewound and the next tried. Nest that -- a variant whose alternatives contain
+   // variants -- and the re-parses multiply, so a failure at the bottom of an ambiguous nest costs
+   // branching^depth. Measured at ~4.3x per level, which turns 189 bytes into 55 seconds.
+   //
+   // The bound is on total work rather than on nesting or attempts, because the honest measure is
+   // how many bytes get parsed more than once. A wide document (many variants side by side, each
+   // rejecting an alternative first) re-parses each element once and stays near 1x its own size,
+   // while the exponential case blows through any multiple of the input immediately. Reads that
+   // exhaust the budget stop speculating and report the failure they already have -- which is the
+   // outcome of a cascade anyway, since it only runs when nothing fits.
+   inline constexpr size_t max_speculative_parse_factor = 8;
+
+   // ...plus a floor, because the factor alone is the wrong shape for small inputs. Resolving a
+   // genuinely ambiguous NESTED variant -- alternatives that differ only in a late member, so the
+   // rejected one parses most of the subtree before failing -- costs exponentially more per level
+   // even when the document is valid and the read ultimately succeeds. Glaze has always been like
+   // this; the bound only decides where it stops. Without a floor, a 265 byte document that read
+   // correctly in 9 ms would now fail, which is not an improvement. A megabyte of speculative bytes
+   // covers that shape to 18 levels, and gives up at 20, which is where the unbounded behavior was
+   // already taking a quarter of a second and doubling every two levels. The adversarial case costs
+   // a constant ~8 ms at any depth instead of 55 seconds at depth 12 and forever beyond that.
+   inline constexpr size_t min_speculative_parse_bytes = 1 << 20;
+
+   // Charge `consumed` bytes of speculative parsing. Returns false once the budget is spent, at which
+   // point the caller must stop trying alternatives. A zero budget means unlimited: a context that
+   // never went through glz::read (a nested or hand-rolled parse) is not policed.
+   [[nodiscard]] GLZ_ALWAYS_INLINE bool charge_speculation(is_context auto& ctx, const size_t consumed) noexcept
+   {
+      if constexpr (requires { ctx.speculation_budget; }) {
+         if (ctx.speculation_budget == 0) {
+            return true;
+         }
+         ctx.speculation_budget = consumed >= ctx.speculation_budget ? 1 : ctx.speculation_budget - consumed;
+         return ctx.speculation_budget > 1;
+      }
+      else {
+         return true;
+      }
+   }
+
+   // True once this read has spent its speculation budget. A stopped resolution must not then be
+   // reinterpreted as success by the non-null-terminated readers' "the iterator advanced, so
+   // something parsed" heuristic: what advanced it was an attempt that was abandoned, not accepted.
+   [[nodiscard]] GLZ_ALWAYS_INLINE bool speculation_exhausted(is_context auto& ctx) noexcept
+   {
+      if constexpr (requires { ctx.speculation_budget; }) {
+         return ctx.speculation_budget == 1; // the latched floor; 0 means unbudgeted
+      }
+      else {
+         return false;
+      }
+   }
+
+   // Manual counterpart of depth_guard for the JSON/NDJSON readers described above: counts one
+   // nesting level and returns true when the limit is reached, in which case the caller must return
+   // immediately. The level is given back with a plain `--ctx.depth` at each syntactic close, so a
+   // bail-out leaves it counted, which is what keeps a truncated buffer from finalizing as success.
+   // Readers that rewind and retry (variant alternatives) must restore ctx.depth with the iterator.
+   [[nodiscard]] GLZ_ALWAYS_INLINE bool enter_depth(is_context auto& ctx) noexcept
+   {
+      if (ctx.depth >= max_recursive_depth_limit) [[unlikely]] {
+         ctx.error = error_code::exceeded_max_recursive_depth;
+         return true;
+      }
+      ++ctx.depth;
+      return false;
+   }
+
    // Runtime constraint concepts
    // These detect if a user-defined context has runtime constraint fields.
    // Users can inherit from glz::context and add these fields for runtime limits:
diff --git a/include/glaze/core/read.hpp b/include/glaze/core/read.hpp
index 5cbc82fca0..ec191059a0 100644
--- a/include/glaze/core/read.hpp
+++ b/include/glaze/core/read.hpp
@@ -79,6 +79,14 @@ namespace glz
          goto finish;
       }
 
+      // Bound the speculative re-parsing a variant resolution may do, in bytes, relative to this
+      // input. Seeded per read so a reused context starts fresh. See charge_speculation.
+      if constexpr (requires { ctx.speculation_budget; }) {
+         const size_t proportional = max_speculative_parse_factor * size_t(end - it);
+         ctx.speculation_budget =
+            (proportional > min_speculative_parse_bytes ? proportional : min_speculative_parse_bytes) + 2;
+      }
+
       if constexpr (use_padded) {
          parse<Opts.format>::template op<is_padded_on<Opts>()>(value, ctx, it, end);
       }
diff --git a/include/glaze/eetf/read.hpp b/include/glaze/eetf/read.hpp
index c902261b5e..c3f02c3fca 100644
--- a/include/glaze/eetf/read.hpp
+++ b/include/glaze/eetf/read.hpp
@@ -1,6 +1,7 @@
 #pragma once
 
 #include <glaze/core/chrono.hpp>
+#include <glaze/core/context.hpp>
 #include <glaze/core/read.hpp>
 #include <glaze/core/reflect.hpp>
 
@@ -140,12 +141,22 @@ namespace glz
       }
    };
 
+   // Recursion depth: a nested EETF term costs a handful of bytes, so input alone can drive the
+   // readers arbitrarily deep and overflow the stack. Every container reader below takes one level
+   // with glz::depth_guard, bounding the descent at max_recursive_depth_limit. Note that skipping is
+   // delegated to ei_skip_term, whose own recursion happens inside erl_interface and cannot be
+   // bounded from here.
    template <readable_array_t T>
    struct from<EETF, T> final
    {
       template <auto Opts, is_context Ctx, class It0, class It1>
       GLZ_ALWAYS_INLINE static void op(auto&& value, Ctx&& ctx, It0&& it, It1&& end) noexcept
       {
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          decode_sequence<Opts>(std::forward<T>(value), std::forward<Ctx>(ctx), std::forward<It0>(it),
                                std::forward<It1>(end));
       }
@@ -242,6 +253,11 @@ namespace glz
             return;
          }
 
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          auto [fields_count, index] = decode_tuple_header(ctx, it);
          if (bool(ctx.error)) [[unlikely]] {
             return;
@@ -283,6 +299,11 @@ namespace glz
             return;
          }
 
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          auto term_it = detail::make_term_iterator<Opts>(ctx, it, end);
          if (bool(ctx.error)) [[unlikely]] {
             return;
@@ -378,6 +399,11 @@ namespace glz
             return;
          }
 
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          auto term_it = detail::make_term_iterator<Opts>(ctx, it, end);
          if (bool(ctx.error)) [[unlikely]] {
             return;
diff --git a/include/glaze/json/read.hpp b/include/glaze/json/read.hpp
index 5c4efdb180..c0b1c8d6b2 100644
--- a/include/glaze/json/read.hpp
+++ b/include/glaze/json/read.hpp
@@ -635,8 +635,10 @@ namespace glz
          if (match_invalid_end<'[', Opts>(ctx, it, end)) {
             return;
          }
-         if constexpr (not Opts.null_terminated) {
-            ++ctx.depth;
+         // one nesting level (see enter_depth): counted in both modes so the limit binds,
+         // released at each syntactic close below
+         if (enter_depth(ctx)) [[unlikely]] {
+            return;
          }
 
          auto* ptr = reinterpret_cast<typename T::value_type*>(&v);
@@ -661,9 +663,7 @@ namespace glz
             return;
          }
          match<']'>(ctx, it);
-         if constexpr (not Opts.null_terminated) {
-            --ctx.depth;
-         }
+         --ctx.depth;
       }
    };
 
@@ -2205,8 +2205,10 @@ namespace glz
          if (match_invalid_end<'[', Opts>(ctx, it, end)) {
             return;
          }
-         if constexpr (not Opts.null_terminated) {
-            ++ctx.depth;
+         // one nesting level (see enter_depth): counted in both modes so the limit binds,
+         // released at each syntactic close below
+         if (enter_depth(ctx)) [[unlikely]] {
+            return;
          }
          if (skip_ws<Opts>(ctx, it, end)) {
             return;
@@ -2214,9 +2216,7 @@ namespace glz
 
          value.clear();
          if (*it == ']') [[unlikely]] {
-            if constexpr (not Opts.null_terminated) {
-               --ctx.depth;
-            }
+            --ctx.depth;
             ++it;
             return;
          }
@@ -2232,9 +2232,7 @@ namespace glz
                return;
             }
             if (*it == ']') {
-               if constexpr (not Opts.null_terminated) {
-                  --ctx.depth;
-               }
+               --ctx.depth;
                ++it;
                return;
             }
@@ -2266,8 +2264,10 @@ namespace glz
          if (match_invalid_end<'[', Opts>(ctx, it, end)) {
             return;
          }
-         if constexpr (not Opts.null_terminated) {
-            ++ctx.depth;
+         // one nesting level (see enter_depth): counted in both modes so the limit binds,
+         // released at each syntactic close below
+         if (enter_depth(ctx)) [[unlikely]] {
+            return;
          }
 
          const auto ws_start = it;
@@ -2276,9 +2276,7 @@ namespace glz
          }
 
          if (*it == ']') {
-            if constexpr (not Opts.null_terminated) {
-               --ctx.depth;
-            }
+            --ctx.depth;
             ++it;
             if constexpr ((resizable<T> || is_inplace_vector<T>) && not check_append_arrays(Opts)) {
                value.clear();
@@ -2319,9 +2317,7 @@ namespace glz
                   }
                }
                else if (*it == ']') {
-                  if constexpr (not Opts.null_terminated) {
-                     --ctx.depth;
-                  }
+                  --ctx.depth;
                   ++it;
                   if constexpr (erasable<T>) {
                      value.erase(value_it,
@@ -2341,6 +2337,9 @@ namespace glz
          }
 
          if constexpr (Opts.partial_read) {
+            // partial_read stops early on a success path, so give the level back like the
+            // syntactic-close paths do; otherwise it accumulates across reads sharing a context.
+            --ctx.depth;
             return;
          }
          else {
@@ -2415,9 +2414,7 @@ namespace glz
                      }
                   }
                   else if (*it == ']') {
-                     if constexpr (not Opts.null_terminated) {
-                        --ctx.depth;
-                     }
+                     --ctx.depth;
                      ++it;
                      if constexpr (check_shrink_to_fit(Opts) && has_shrink_to_fit<T>) {
                         value.shrink_to_fit();
@@ -2469,8 +2466,10 @@ namespace glz
                   return;
                }
             }
-            if constexpr (not Opts.null_terminated) {
-               ++ctx.depth;
+            // one nesting level (see enter_depth): counted in both modes so the limit binds,
+            // released at each syntactic close below
+            if (enter_depth(ctx)) [[unlikely]] {
+               return;
             }
          }
 
@@ -2484,9 +2483,7 @@ namespace glz
 
             if (*it == '}') {
                ++it;
-               if constexpr (not Opts.null_terminated) {
-                  --ctx.depth;
-               }
+               --ctx.depth;
                if constexpr (not Opts.null_terminated) {
                   if (it == end) {
                      ctx.error = error_code::end_reached;
@@ -2656,8 +2653,10 @@ namespace glz
          if (match_invalid_end<'[', Opts>(ctx, it, end)) {
             return;
          }
-         if constexpr (not Opts.null_terminated) {
-            ++ctx.depth;
+         // one nesting level (see enter_depth): counted in both modes so the limit binds,
+         // released at each syntactic close below
+         if (enter_depth(ctx)) [[unlikely]] {
+            return;
          }
          const auto n = number_of_array_elements<Opts>(ctx, it, end);
          if (bool(ctx.error)) [[unlikely]]
@@ -2680,9 +2679,7 @@ namespace glz
             ++i;
          }
          match<']'>(ctx, it);
-         if constexpr (not Opts.null_terminated) {
-            --ctx.depth;
-         }
+         --ctx.depth;
       }
    };
 
@@ -2711,8 +2708,10 @@ namespace glz
          if (match_invalid_end<'[', Opts>(ctx, it, end)) {
             return;
          }
-         if constexpr (not Opts.null_terminated) {
-            ++ctx.depth;
+         // one nesting level (see enter_depth): counted in both modes so the limit binds,
+         // released at each syntactic close below
+         if (enter_depth(ctx)) [[unlikely]] {
+            return;
          }
          if (skip_ws<Opts>(ctx, it, end)) {
             return;
@@ -2758,15 +2757,14 @@ namespace glz
          });
 
          if constexpr (Opts.partial_read) {
+            --ctx.depth; // as above: an early success still closes this level
             return;
          }
          else {
             if (bool(ctx.error)) [[unlikely]]
                return;
             match<']'>(ctx, it);
-            if constexpr (not Opts.null_terminated) {
-               --ctx.depth;
-            }
+            --ctx.depth;
             if constexpr (not Opts.null_terminated) {
                if (it == end) {
                   ctx.error = error_code::end_reached;
@@ -2792,8 +2790,10 @@ namespace glz
          if (match_invalid_end<'[', Opts>(ctx, it, end)) {
             return;
          }
-         if constexpr (not Opts.null_terminated) {
-            ++ctx.depth;
+         // one nesting level (see enter_depth): counted in both modes so the limit binds,
+         // released at each syntactic close below
+         if (enter_depth(ctx)) [[unlikely]] {
+            return;
          }
 
          constexpr auto& HashInfo = hash_info<T>;
@@ -2820,9 +2820,7 @@ namespace glz
                return;
             }
             if (*it == ']') {
-               if constexpr (not Opts.null_terminated) {
-                  --ctx.depth;
-               }
+               --ctx.depth;
                ++it;
                if constexpr (not Opts.null_terminated) {
                   if (it == end) {
@@ -2900,8 +2898,10 @@ namespace glz
             if (match_invalid_end<'{', Opts>(ctx, it, end)) {
                return;
             }
-            if constexpr (not Opts.null_terminated) {
-               ++ctx.depth;
+            // one nesting level (see enter_depth): counted in both modes so the limit binds,
+            // released at each syntactic close below
+            if (enter_depth(ctx)) [[unlikely]] {
+               return;
             }
          }
          if (skip_ws<Opts>(ctx, it, end)) {
@@ -2909,9 +2909,7 @@ namespace glz
          }
 
          if (*it == '}') {
-            if constexpr (not Opts.null_terminated) {
-               --ctx.depth;
-            }
+            --ctx.depth;
             if constexpr (Opts.error_on_missing_keys) {
                ctx.error = error_code::missing_key;
             }
@@ -2958,9 +2956,7 @@ namespace glz
          }
 
          match<'}'>(ctx, it);
-         if constexpr (not Opts.null_terminated) {
-            --ctx.depth;
-         }
+         --ctx.depth;
          if constexpr (not Opts.null_terminated) {
             if (it == end) {
                ctx.error = error_code::end_reached;
@@ -3012,8 +3008,10 @@ namespace glz
                   return;
                }
             }
-            if constexpr (not Opts.null_terminated) {
-               ++ctx.depth;
+            // one nesting level (see enter_depth): counted in both modes so the limit binds,
+            // released at each syntactic close below
+            if (enter_depth(ctx)) [[unlikely]] {
+               return;
             }
          }
          const auto ws_start = it;
@@ -3067,9 +3065,7 @@ namespace glz
             }
 
             if (*it == '}') [[likely]] {
-               if constexpr (not Opts.null_terminated) {
-                  --ctx.depth;
-               }
+               --ctx.depth;
                if constexpr (glaze_object_t<T> || reflectable<T>) {
                   if constexpr (has_self_constraint_v<T> && !check_skip_self_constraint(Opts)) {
                      auto wrapper = self_constraint_v<T>(value);
@@ -3120,6 +3116,7 @@ namespace glz
 
                   if ((all_fields & fields) == all_fields) {
                      ctx.error = error_code::partial_read_complete;
+                     --ctx.depth; // as above: an early success still closes this level
                      return;
                   }
                }
@@ -3145,9 +3142,7 @@ namespace glz
                }
 
                if (*it == '}') {
-                  if constexpr (not Opts.null_terminated) {
-                     --ctx.depth;
-                  }
+                  --ctx.depth;
                   if constexpr ((glaze_object_t<T> || reflectable<T>) && Opts.error_on_missing_keys) {
                      constexpr auto req_fields = required_fields<T, Opts>();
                      if ((req_fields & fields) != req_fields) {
@@ -3625,6 +3620,42 @@ namespace glz
          return std::variant_npos;
    }();
 
+   // Restores a nesting-depth snapshot after a rejected variant alternative, alongside the iterator
+   // rewind. A rejected alternative that bailed out inside a container left its level counted (see
+   // enter_depth), and without this, trying N alternatives could spend N levels of the recursion
+   // budget -- a long array of variants would eventually report exceeded_max_recursive_depth.
+   //
+   // The exception is a truncation signal on a non-null-terminated buffer: there ctx.depth is also
+   // the completion counter, and leaving that level counted is precisely what tells
+   // finalize_read_context the buffer ended mid-value rather than exactly at a clean close.
+   //
+   // Nesting past the limit is excluded in both modes, and for a third reason: handing the next
+   // alternative a fresh budget lets it re-descend to the limit and fail identically, so the retry
+   // re-parses the whole subtree once per alternative at every level of the nest, which is
+   // exponential. `variant_alternative_exhausted` below stops the retry outright.
+   template <auto Opts>
+   GLZ_ALWAYS_INLINE void rewind_depth(is_context auto& ctx, const uint32_t depth) noexcept
+   {
+      if (ctx.error == error_code::exceeded_max_recursive_depth) [[unlikely]] {
+         return;
+      }
+      if constexpr (Opts.null_terminated) {
+         ctx.depth = depth;
+      }
+      else {
+         if (ctx.error != error_code::end_reached && ctx.error != error_code::unexpected_end) {
+            ctx.depth = depth;
+         }
+      }
+   }
+
+   // True once a failure means no further alternative is worth trying: nesting past the depth limit
+   // is a property of the input rather than of the alternative, so retrying only multiplies the work.
+   GLZ_ALWAYS_INLINE bool variant_alternative_exhausted(is_context auto& ctx) noexcept
+   {
+      return ctx.error == error_code::exceeded_max_recursive_depth;
+   }
+
    // Process variant alternatives by iterating directly over variant indices
    // (replaces tuple_cat + tuple iteration approach)
    template <class Variant, template <class> class Trait>
@@ -3644,11 +3675,15 @@ namespace glz
             constexpr auto non_const_count = variant_filtered_count_v<Variant, Trait, false>;
 
             bool found_match{};
+            // Set when the speculative parsing budget runs out; see charge_speculation. An ambiguous
+            // nest re-parses the same subtree per alternative at every level, which is exponential,
+            // so the budget stops it and the failure already in hand is reported.
+            bool exhausted{};
 
             // First pass: const glaze types in this category
             if constexpr (const_count > 0) {
                for_each<N>([&]<size_t I>() {
-                  if (found_match) {
+                  if (found_match || exhausted || variant_alternative_exhausted(ctx)) {
                      return;
                   }
                   using V = std::variant_alternative_t<I, Variant>;
@@ -3656,6 +3691,10 @@ namespace glz
                      // run time substitute to compare to const value
                      std::remove_const_t<std::remove_pointer_t<std::remove_const_t<meta_wrapper_t<V>>>> substitute{};
                      auto copy_it{it};
+                     // A rejected alternative may have bailed out inside a container, which leaves its
+                     // nesting level counted (see enter_depth). Rewind the depth with the iterator so
+                     // trying N alternatives cannot spend N levels of the recursion budget.
+                     const auto copy_depth{ctx.depth};
                      parse<JSON>::op<ws_handled<Options>()>(substitute, ctx, it, end);
                      static constexpr auto const_value{*meta_wrapper_v<V>};
                      if (substitute == const_value) {
@@ -3665,12 +3704,17 @@ namespace glz
                         }
                      }
                      else {
+                        // Rejected: charge the bytes it parsed before we rewind. See charge_speculation.
+                        if (!charge_speculation(ctx, size_t(it - copy_it))) {
+                           exhausted = true;
+                        }
                         if constexpr (not Options.null_terminated) {
-                           if (ctx.error == error_code::end_reached) {
+                           if (ctx.error == error_code::end_reached && not exhausted) {
                               ctx.error = error_code::none;
                            }
                         }
                         it = copy_it;
+                        rewind_depth<Options>(ctx, copy_depth);
                      }
                   }
                });
@@ -3689,16 +3733,20 @@ namespace glz
                size_t non_const_idx = 0;
 
                for_each<N>([&]<size_t I>() {
-                  if (found_match) {
+                  if (found_match || exhausted || variant_alternative_exhausted(ctx)) {
                      return;
                   }
                   using V = std::variant_alternative_t<I, Variant>;
                   if constexpr (Trait<V>::value && !glaze_const_value_t<V>) {
                      auto copy_it{it};
+                     const auto copy_depth{ctx.depth}; // rewound with `it`; see the note above
                      if (!std::holds_alternative<V>(value)) {
                         value = V{};
                      }
                      parse<JSON>::op<ws_handled<Options>()>(std::get<V>(value), ctx, it, end);
+                     if (bool(ctx.error) && !charge_speculation(ctx, size_t(it - copy_it))) {
+                        exhausted = true; // only rejected attempts are charged; see charge_speculation
+                     }
                      if (!bool(ctx.error)) {
                         found_match = true;
                      }
@@ -3707,27 +3755,33 @@ namespace glz
                            num_t<V> || bool_t<V> || string_t<V> || std::is_enum_v<V> || tuple_t<V> || is_std_tuple<V>;
 
                         if constexpr (is_complete_type) {
-                           if (ctx.error == error_code::end_reached && it > copy_it) {
+                           if (ctx.error == error_code::end_reached && it > copy_it && not speculation_exhausted(ctx)) {
                               found_match = true;
                               ctx.error = error_code::none;
                            }
                            else {
                               it = copy_it;
-                              if (non_const_idx + 1 < non_const_count) {
+                              rewind_depth<Options>(ctx, copy_depth);
+                              if (non_const_idx + 1 < non_const_count && not exhausted &&
+                                  not variant_alternative_exhausted(ctx)) {
                                  ctx.error = error_code::none;
                               }
                            }
                         }
                         else {
                            it = copy_it;
-                           if (non_const_idx + 1 < non_const_count) {
+                           rewind_depth<Options>(ctx, copy_depth);
+                           if (non_const_idx + 1 < non_const_count && not exhausted &&
+                               not variant_alternative_exhausted(ctx)) {
                               ctx.error = error_code::none;
                            }
                         }
                      }
                      else {
                         it = copy_it;
-                        if (non_const_idx + 1 < non_const_count) {
+                        rewind_depth<Options>(ctx, copy_depth);
+                        if (non_const_idx + 1 < non_const_count && not exhausted &&
+                            not variant_alternative_exhausted(ctx)) {
                            ctx.error = error_code::none;
                         }
                      }
@@ -3738,7 +3792,8 @@ namespace glz
                   if constexpr (non_const_count == 1) {
                      // Keep the specific error from the single type we tried
                   }
-                  else {
+                  else if (not exhausted && not variant_alternative_exhausted(ctx)) {
+                     // Over-nested input is not a failure of the alternative set; keep saying so.
                      ctx.error = error_code::no_matching_variant_type;
                   }
                }
@@ -3958,13 +4013,13 @@ namespace glz
                ctx.error = error_code::unexpected_end;
                return;
             case '{': {
-               if (ctx.depth >= max_recursive_depth_limit) {
-                  ctx.error = error_code::exceeded_max_recursive_depth;
+               // This branch consumes the brace itself and hands the object to a reader with
+               // `opening_handled`, which therefore does not count the level -- but does release it at
+               // the closing brace. So the level is taken here, in both buffer modes, and paid back by
+               // the reader below.
+               if (enter_depth(ctx)) [[unlikely]] {
                   return;
                }
-               // In the null terminated case this guards for stack overflow
-               // Depth counting is done at the object level when not null terminated
-               ++ctx.depth;
 
                ++it;
                if constexpr (not Opts.null_terminated) {
@@ -3984,11 +4039,6 @@ namespace glz
                   using V = std::variant_alternative_t<first_idx, T>;
                   if (!std::holds_alternative<V>(value)) value = V{};
                   parse<JSON>::op<opening_handled<Opts>()>(std::get<V>(value), ctx, it, end);
-                  if constexpr (Opts.null_terminated) {
-                     // In the null terminated case this guards for stack overflow
-                     // Depth counting is done at the object level when not null terminated
-                     --ctx.depth;
-                  }
                   return;
                }
                else {
@@ -4178,11 +4228,6 @@ namespace glz
                                     },
                                     value);
 
-                                 if constexpr (Opts.null_terminated) {
-                                    // In the null terminated case this guards for stack overflow
-                                    // Depth counting is done at the object level when not null terminated
-                                    --ctx.depth;
-                                 }
                                  return; // we've decoded our target type
                               }
                               else [[unlikely]] {
@@ -4269,9 +4314,6 @@ namespace glz
                                        },
                                        value);
 
-                                    if constexpr (Opts.null_terminated) {
-                                       --ctx.depth;
-                                    }
                                     return;
                                  }
                                  else {
@@ -4364,9 +4406,6 @@ namespace glz
                                  }
                               },
                               value);
-                           if constexpr (Opts.null_terminated) {
-                              --ctx.depth;
-                           }
                            return;
                         }
                         else if constexpr (Opts.error_on_unknown_keys) {
@@ -4461,11 +4500,6 @@ namespace glz
                               },
                               value);
 
-                           if constexpr (Opts.null_terminated) {
-                              // In the null terminated case this guards for stack overflow
-                              // Depth counting is done at the object level when not null terminated
-                              --ctx.depth;
-                           }
                            return; // we've decoded our target type
                         }
                      }
@@ -4556,36 +4590,30 @@ namespace glz
                               }
                            },
                            value);
-
-                        if constexpr (Opts.null_terminated) {
-                           --ctx.depth;
-                        }
                      }
                      else if (final_matching > 1) {
                         constexpr auto N = std::variant_size_v<T>;
 
                         // Compile-time array of field counts for each variant type
                         constexpr auto field_counts = []<size_t... I>(std::index_sequence<I...>) {
-                           return std::array<size_t, N> {
-                              ([]<size_t J = I>() -> size_t {
-                                 using V = std::decay_t<std::variant_alternative_t<J, T>>;
-                                 if constexpr (glaze_object_t<V> || reflectable<V>) {
-                                    return reflect<V>::size;
-                                 }
-                                 else if constexpr (is_memory_object<V>) {
-                                    using X = memory_type<V>;
-                                    if constexpr (glaze_object_t<X> || reflectable<X>) {
-                                       return reflect<X>::size;
-                                    }
-                                    else {
-                                       return (std::numeric_limits<size_t>::max)();
-                                    }
+                           return std::array<size_t, N>{([]<size_t J = I>() -> size_t {
+                              using V = std::decay_t<std::variant_alternative_t<J, T>>;
+                              if constexpr (glaze_object_t<V> || reflectable<V>) {
+                                 return reflect<V>::size;
+                              }
+                              else if constexpr (is_memory_object<V>) {
+                                 using X = memory_type<V>;
+                                 if constexpr (glaze_object_t<X> || reflectable<X>) {
+                                    return reflect<X>::size;
                                  }
                                  else {
                                     return (std::numeric_limits<size_t>::max)();
                                  }
-                              }.template operator()<I>())...
-                           };
+                              }
+                              else {
+                                 return (std::numeric_limits<size_t>::max)();
+                              }
+                           }.template operator()<I>())...};
                         }(std::make_index_sequence<N>{});
 
                         // Find the type with minimum field count among the possible types
@@ -4661,10 +4689,6 @@ namespace glz
                                  }
                               },
                               value);
-
-                           if constexpr (Opts.null_terminated) {
-                              --ctx.depth;
-                           }
                         }
                         else {
                            ctx.error = error_code::no_matching_variant_type;
@@ -4679,19 +4703,8 @@ namespace glz
                break;
             }
             case '[':
-               if (ctx.depth >= max_recursive_depth_limit) {
-                  ctx.error = error_code::exceeded_max_recursive_depth;
-                  return;
-               }
-               if constexpr (Opts.null_terminated) {
-                  // In the null terminated case this guards for stack overflow
-                  // Depth counting is done at the object level when not null terminated
-                  ++ctx.depth;
-               }
+               // As with '{' above: the array reader each alternative goes through counts the level.
                process_variant_alternatives<T, is_variant_array>::template op<Opts>(value, ctx, it, end);
-               if constexpr (Opts.null_terminated) {
-                  --ctx.depth;
-               }
                break;
             case '"': {
                process_variant_alternatives<T, is_variant_str>::template op<Opts>(value, ctx, it, end);
@@ -4723,11 +4736,15 @@ namespace glz
             // For non-auto-deducible variants, try each type until one succeeds
             constexpr auto N = std::variant_size_v<T>;
             bool parsed = false;
+            bool exhausted = false; // speculation budget spent; see charge_speculation
 
             for_each<N>([&]<size_t I>() {
-               if (parsed) return;
+               if (parsed || exhausted || variant_alternative_exhausted(ctx)) return;
 
                auto copy_it = it;
+               // A rejected alternative may have bailed out inside a container, which leaves its
+               // nesting level counted (see enter_depth), so the depth rewinds with the iterator.
+               const auto copy_depth = ctx.depth;
 
                // Try parsing as this type
                if (value.index() != I) {
@@ -4735,6 +4752,9 @@ namespace glz
                }
 
                std::visit([&](auto&& v) { parse<JSON>::op<Options>(v, ctx, it, end); }, value);
+               if (bool(ctx.error) && !charge_speculation(ctx, size_t(it - copy_it))) {
+                  exhausted = true; // only rejected attempts are charged; see charge_speculation
+               }
 
                if (!bool(ctx.error)) {
                   parsed = true;
@@ -4742,24 +4762,31 @@ namespace glz
                else if constexpr (not Options.null_terminated) {
                   // In non-null-terminated mode, if we hit end_reached after advancing the iterator,
                   // it means we successfully parsed the value but couldn't skip trailing whitespace
-                  if (ctx.error == error_code::end_reached && it > copy_it) {
-                     // We advanced the iterator, so we did parse something
+                  if (ctx.error == error_code::end_reached && it > copy_it && not speculation_exhausted(ctx)) {
+                     // We advanced the iterator, so we did parse something -- unless what advanced it
+                     // was an abandoned attempt, which is what a spent speculation budget means.
                      parsed = true;
                      ctx.error = error_code::none;
                   }
                   else {
                      // Reset for next attempt (unless this is the last type)
                      it = copy_it;
+                     rewind_depth<Options>(ctx, copy_depth);
                      if constexpr (I + 1 < N) {
-                        ctx.error = error_code::none; // Clear error for next attempt
+                        if (not exhausted && not variant_alternative_exhausted(ctx)) {
+                           ctx.error = error_code::none; // Clear error for next attempt
+                        }
                      }
                   }
                }
                else {
                   // Reset for next attempt (unless this is the last type)
                   it = copy_it;
+                  rewind_depth<Options>(ctx, copy_depth);
                   if constexpr (I + 1 < N) {
-                     ctx.error = error_code::none; // Clear error for next attempt
+                     if (not exhausted && not variant_alternative_exhausted(ctx)) {
+                        ctx.error = error_code::none; // Clear error for next attempt
+                     }
                   }
                }
             });
@@ -4785,8 +4812,10 @@ namespace glz
          if (match_invalid_end<'[', Opts>(ctx, it, end)) {
             return;
          }
-         if constexpr (not Opts.null_terminated) {
-            ++ctx.depth;
+         // one nesting level (see enter_depth): counted in both modes so the limit binds,
+         // released at each syntactic close below
+         if (enter_depth(ctx)) [[unlikely]] {
+            return;
          }
          if (skip_ws<Opts>(ctx, it, end)) {
             return;
@@ -4834,9 +4863,7 @@ namespace glz
             return;
          }
          match<']'>(ctx, it);
-         if constexpr (not Opts.null_terminated) {
-            --ctx.depth;
-         }
+         --ctx.depth;
       }
    };
 
@@ -4868,8 +4895,15 @@ namespace glz
          };
 
          if (*it == '{') {
-            if constexpr (not Opts.null_terminated) {
-               ++ctx.depth;
+            // One level, held only while this reader scans the wrapper. Every path below releases it:
+            // the two that rewind to `start` do so before delegating, since the reader they hand the
+            // object to counts the level itself, and the {"unexpected": value} branch releases at the
+            // closing brace. Releasing on all three matters because this is the ordinary path -- a
+            // flat array of a few hundred expected values would otherwise spend the whole budget --
+            // and holding it on the error paths is what keeps a truncated wrapper from finalizing as
+            // success on a non-null-terminated buffer.
+            if (enter_depth(ctx)) [[unlikely]] {
+               return;
             }
             auto start = it;
             ++it;
@@ -4883,6 +4917,7 @@ namespace glz
                return;
             }
             if (*it == '}') {
+               --ctx.depth; // released before delegating; parse_val re-reads the object and counts it
                it = start;
                // empty object
                parse_val();
@@ -4919,11 +4954,10 @@ namespace glz
                      return;
                   }
                   match<'}'>(ctx, it);
-                  if constexpr (not Opts.null_terminated) {
-                     --ctx.depth;
-                  }
+                  --ctx.depth;
                }
                else {
+                  --ctx.depth; // as above: released before delegating
                   it = start;
                   parse_val();
                }
diff --git a/include/glaze/json/skip.hpp b/include/glaze/json/skip.hpp
index 6ecb1e5238..9248af75dd 100644
--- a/include/glaze/json/skip.hpp
+++ b/include/glaze/json/skip.hpp
@@ -34,8 +34,10 @@ namespace glz
          skip_until_closed<Opts, '{', '}'>(ctx, it, end);
       }
       else {
-         if constexpr (not Opts.null_terminated) {
-            ++ctx.depth;
+         // one nesting level (see enter_depth): counted in both modes so the limit binds,
+         // released at each syntactic close below
+         if (enter_depth(ctx)) [[unlikely]] {
+            return;
          }
          ++it;
          if constexpr (not Opts.null_terminated) {
@@ -48,9 +50,7 @@ namespace glz
             return;
          }
          if (*it == '}') {
-            if constexpr (not Opts.null_terminated) {
-               --ctx.depth;
-            }
+            --ctx.depth;
             ++it;
             if constexpr (not Opts.null_terminated) {
                if (it == end) {
@@ -96,9 +96,7 @@ namespace glz
             }
          }
          match<'}'>(ctx, it);
-         if constexpr (not Opts.null_terminated) {
-            --ctx.depth;
-         }
+         --ctx.depth;
          if constexpr (not Opts.null_terminated) {
             if (it == end) {
                ctx.error = error_code::end_reached;
@@ -123,8 +121,10 @@ namespace glz
          skip_until_closed<Opts, '[', ']'>(ctx, it, end);
       }
       else {
-         if constexpr (not Opts.null_terminated) {
-            ++ctx.depth;
+         // one nesting level (see enter_depth): counted in both modes so the limit binds,
+         // released at each syntactic close below
+         if (enter_depth(ctx)) [[unlikely]] {
+            return;
          }
          ++it;
          if constexpr (not Opts.null_terminated) {
@@ -137,9 +137,7 @@ namespace glz
             return;
          }
          if (*it == ']') {
-            if constexpr (not Opts.null_terminated) {
-               --ctx.depth;
-            }
+            --ctx.depth;
             ++it;
             if constexpr (not Opts.null_terminated) {
                if (it == end) {
@@ -169,9 +167,7 @@ namespace glz
             }
          }
          match<']'>(ctx, it);
-         if constexpr (not Opts.null_terminated) {
-            --ctx.depth;
-         }
+         --ctx.depth;
          if constexpr (not Opts.null_terminated) {
             if (it == end) {
                ctx.error = error_code::end_reached;
diff --git a/include/glaze/msgpack/read.hpp b/include/glaze/msgpack/read.hpp
index a12593cef9..0d9ffbef09 100644
--- a/include/glaze/msgpack/read.hpp
+++ b/include/glaze/msgpack/read.hpp
@@ -26,6 +26,13 @@
 #include "glaze/util/string_literal.hpp"
 #include "glaze/util/variant.hpp"
 
+// Recursion depth: a fixarray or fixmap nesting level costs a single byte, so input alone can drive
+// the reader arbitrarily deep and overflow the stack. Every reader that reads an array or map length
+// and then descends into the entries takes one level with glz::depth_guard, and skip_value<MSGPACK>
+// does the same, which bounds the descent at max_recursive_depth_limit and reports
+// exceeded_max_recursive_depth instead of crashing. A reader that delegates to another reader (a
+// reflectable struct defers to the tuple reader) leaves the level to that reader.
+
 namespace glz::msgpack::detail
 {
    template <class It>
@@ -598,6 +605,11 @@ namespace glz
       template <auto Opts, class Value, is_context Ctx, class It, class End>
       GLZ_ALWAYS_INLINE static void op(Value&& value, uint8_t tag, Ctx&& ctx, It& it, const End& end) noexcept
       {
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          if constexpr (check_structs_as_arrays(Opts)) {
             size_t len{};
             if (!msgpack::read_array_length(ctx, tag, it, end, len)) {
@@ -738,6 +750,11 @@ namespace glz
       template <auto Opts, class Value, is_context Ctx, class It, class End>
       GLZ_ALWAYS_INLINE static void op(Value&& value, uint8_t tag, Ctx&& ctx, It& it, const End& end) noexcept
       {
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          size_t len{};
          if (!msgpack::read_map_length(ctx, tag, it, end, len)) {
             return;
@@ -791,6 +808,11 @@ namespace glz
       template <auto Opts, class Value, is_context Ctx, class It, class End>
       GLZ_ALWAYS_INLINE static void op(Value&& value, uint8_t tag, Ctx&& ctx, It& it, const End& end) noexcept
       {
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          size_t len{};
          if (!msgpack::read_array_length(ctx, tag, it, end, len)) {
             return;
@@ -853,6 +875,12 @@ namespace glz
             }
          }
 
+         // Placed after the binary-range branch above, which is flat and returns before this point.
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          size_t len{};
          if (!msgpack::read_array_length(ctx, tag, it, end, len)) {
             return;
@@ -1008,6 +1036,11 @@ namespace glz
       template <auto Opts, class Value, is_context Ctx, class It, class End>
       GLZ_ALWAYS_INLINE static void op(Value&& value, uint8_t tag, Ctx&& ctx, It& it, const End& end) noexcept
       {
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          size_t len{};
          if (!msgpack::read_array_length(ctx, tag, it, end, len)) {
             return;
@@ -1032,6 +1065,11 @@ namespace glz
       template <auto Opts, class Value, is_context Ctx, class It, class End>
       GLZ_ALWAYS_INLINE static void op(Value&& value, uint8_t tag, Ctx&& ctx, It& it, const End& end) noexcept
       {
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          size_t len{};
          if (!msgpack::read_array_length(ctx, tag, it, end, len)) {
             return;
@@ -1061,6 +1099,11 @@ namespace glz
       template <auto Opts, class Value, is_context Ctx, class It, class End>
       GLZ_ALWAYS_INLINE static void op(Value&& value, uint8_t tag, Ctx&& ctx, It& it, const End& end) noexcept
       {
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          size_t len{};
          if (!msgpack::read_array_length(ctx, tag, it, end, len)) {
             return;
@@ -1124,6 +1167,11 @@ namespace glz
       template <auto Opts, class Value, is_context Ctx, class It, class End>
       GLZ_ALWAYS_INLINE static void op(Value&& value, uint8_t tag, Ctx&& ctx, It& it, const End& end) noexcept
       {
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          size_t len{};
          if (!msgpack::read_array_length(ctx, tag, it, end, len)) {
             return;
@@ -1149,6 +1197,11 @@ namespace glz
       template <auto Opts, class Value, is_context Ctx, class It, class End>
       GLZ_ALWAYS_INLINE static void op(Value&& value, uint8_t tag, Ctx&& ctx, It& it, const End& end) noexcept
       {
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          size_t len{};
          if (!msgpack::read_array_length(ctx, tag, it, end, len)) {
             return;
@@ -1174,6 +1227,11 @@ namespace glz
       template <auto Opts, class Value, is_context Ctx, class It, class End>
       GLZ_ALWAYS_INLINE static void op(Value&& value, uint8_t tag, Ctx&& ctx, It& it, const End& end) noexcept
       {
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          size_t len{};
          if (!msgpack::read_map_length(ctx, tag, it, end, len)) {
             return;
@@ -1202,6 +1260,11 @@ namespace glz
       template <auto Opts, class Value, is_context Ctx, class It, class End>
       GLZ_ALWAYS_INLINE static void op(Value&& value, uint8_t tag, Ctx&& ctx, It& it, const End& end) noexcept
       {
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          size_t len{};
          if (!msgpack::read_map_length(ctx, tag, it, end, len)) {
             return;
diff --git a/include/glaze/msgpack/skip.hpp b/include/glaze/msgpack/skip.hpp
index d466e2649c..f645c58c82 100644
--- a/include/glaze/msgpack/skip.hpp
+++ b/include/glaze/msgpack/skip.hpp
@@ -3,6 +3,7 @@
 
 #pragma once
 
+#include "glaze/core/context.hpp"
 #include "glaze/core/opts.hpp"
 #include "glaze/msgpack/common.hpp"
 
@@ -39,6 +40,14 @@ namespace glz
             return;
          }
 
+         // Arrays and maps skip their children by calling back into op, so this one guard bounds every
+         // recursive skip path. A fixarray nesting level costs a single byte, so without it a tiny
+         // hostile buffer overflows the stack before it runs out of items to skip.
+         depth_guard guard{ctx};
+         if (!guard) [[unlikely]] {
+            return;
+         }
+
          switch (tag) {
          case msgpack::nil:
          case msgpack::bool_false:


Test patch:
diff --git a/tests/beve_test/beve_test.cpp b/tests/beve_test/beve_test.cpp
index b837e77c39..f99138d8c2 100644
--- a/tests/beve_test/beve_test.cpp
+++ b/tests/beve_test/beve_test.cpp
@@ -4752,9 +4752,8 @@ suite beve_v2_variant_resolution = [] {
       // subtree, making the read quadratic in depth. Assert on the shape of the growth rather than
       // absolute time, which varies far too much across CI machines.
       //
-      // Depth is capped at 200 (~230 KB of stack): reading recurses once per level, and Windows
-      // gives the main thread 1 MB by default, so a deeper tree overflows the stack in a Debug
-      // build rather than measuring anything. Each buffer is read many times so the ratio does not
+      // Depth is capped at 200 because the reader rejects anything past max_recursive_depth_limit
+      // (256), one level per nested object. Each buffer is read many times so the ratio does not
       // rest on a single sub-millisecond sample.
       auto build = [](int depth) {
          deep_v v{deep_leaf{1}};
@@ -4788,6 +4787,16 @@ suite beve_v2_variant_resolution = [] {
       const auto t_deep = bench_ms(deep, reps);
       // Measured 3.9-4.1x linear against 13.3-14.2x quadratic, so 8x separates them with margin.
       expect(t_deep < t_shallow * 8.0) << "read time grew " << (t_deep / t_shallow) << "x for 4x the depth";
+
+      // Past the limit, every level fails identically. Each of the variant reader's recovery paths
+      // -- the other object alternatives, the lenient conversion pass, the last-resort try_each --
+      // would re-parse the whole subtree for a failure no alternative can fix, once per level, which
+      // is exponential rather than merely wasteful: 300 levels did not finish in over an hour. This
+      // must error immediately, so a regression shows up as a hung test rather than a failed
+      // assertion.
+      deep_v past_limit{};
+      expect(glz::read_beve(past_limit, build(glz::max_recursive_depth_limit + 50)) ==
+             glz::error_code::exceeded_max_recursive_depth);
    };
 
    "a failed deduction falls back to the other object alternatives"_test = [] {
@@ -8844,6 +8853,217 @@ suite beve_variant_tagging_representation = [] {
    };
 };
 
+namespace beve_depth
+{
+   struct alt_a
+   {
+      int a{};
+   };
+   struct alt_b
+   {
+      int b{};
+   };
+   using two_object_v = std::variant<alt_a, alt_b>;
+
+   struct one_field
+   {
+      int a{};
+   };
+
+   // Recursive types: input alone decides how deep the reader descends.
+   struct list_node
+   {
+      std::unique_ptr<list_node> next{};
+      int n{};
+   };
+   struct tree_node
+   {
+      std::vector<tree_node> children{};
+   };
+
+   // { "zz": [[[[ ... ]]]] } -- two bytes per nesting level, and "zz" belongs to no alternative, so a
+   // variant's key scan skips the whole nest and a lax struct read skips it as an unknown key.
+   inline std::string nested_arrays(size_t levels)
+   {
+      std::string b;
+      b.push_back(char(glz::tag::object));
+      b.push_back(char(1 << 2)); // one key
+      b.push_back(char(2 << 2)); // key length
+      b += "zz";
+      for (size_t i = 0; i < levels; ++i) {
+         b.push_back(char(glz::tag::generic_array));
+         b.push_back(char(1 << 2)); // one element
+      }
+      b.push_back(char(glz::tag::null));
+      return b;
+   }
+
+   inline std::string nested_list(size_t levels)
+   {
+      list_node root{};
+      auto* cur = &root;
+      for (size_t i = 0; i < levels; ++i) {
+         cur->next = std::make_unique<list_node>();
+         cur = cur->next.get();
+      }
+      return glz::write_beve(root).value();
+   }
+
+   inline std::string nested_tree(size_t levels)
+   {
+      tree_node root{};
+      auto* cur = &root;
+      for (size_t i = 0; i < levels; ++i) {
+         cur->children.emplace_back();
+         cur = &cur->children.front();
+      }
+      return glz::write_beve(root).value();
+   }
+
+   // Two object alternatives with identical key sets, so deduction never narrows to one candidate
+   // and resolution has to try them. Recursive, so the retries nest.
+   struct amb_node_a;
+   struct amb_node_b;
+   struct amb_leaf
+   {
+      int v{};
+   };
+   using amb_v = std::variant<amb_leaf, std::shared_ptr<amb_node_a>, std::shared_ptr<amb_node_b>>;
+   struct amb_node_a
+   {
+      amb_v child{};
+      int n{};
+   };
+   struct amb_node_b
+   {
+      amb_v child{};
+      int n{};
+   };
+
+   inline std::string ambiguous_nest(size_t levels)
+   {
+      amb_v v{amb_leaf{1}};
+      for (size_t i = 0; i < levels; ++i) {
+         auto n = std::make_shared<amb_node_b>();
+         n->child = std::move(v);
+         n->n = int(i);
+         v = std::move(n);
+      }
+      auto buffer = glz::write_beve(v).value();
+      // Rename the innermost leaf's only key so the bottom of the nest fails with unknown_key. Its
+      // key is the last "v" written, and no later byte can be one: what follows is the leaf's
+      // numeric value and then each enclosing node's "n" key and value.
+      buffer[buffer.rfind('v')] = 'q';
+      return buffer;
+   }
+}
+
+suite beve_recursion_depth_limit = [] {
+   using namespace beve_depth;
+
+   "a hostile nest of arrays is rejected rather than overflowing the stack"_test = [] {
+      // Two bytes per level: 200k levels is 400 KB of input against a default 8 MB stack (1 MB on
+      // Windows). The read must stop at max_recursive_depth_limit instead of recursing to a crash.
+      const auto buffer = nested_arrays(200'000);
+
+      two_object_v variant_out{};
+      expect(glz::read_beve(variant_out, buffer) == glz::error_code::exceeded_max_recursive_depth);
+
+      // error_on_unknown_keys off is what lets a plain struct reach the same skip path.
+      constexpr glz::opts lax{.format = glz::BEVE, .error_on_unknown_keys = false};
+      one_field struct_out{};
+      expect(glz::read<lax>(struct_out, buffer) == glz::error_code::exceeded_max_recursive_depth);
+
+      // The transcoder has always been guarded; the reader now agrees with it.
+      std::string json{};
+      expect(glz::beve_to_json(buffer, json) == glz::error_code::exceeded_max_recursive_depth);
+   };
+
+   "the limit binds the typed readers, not just skipping"_test = [] {
+      // A recursive struct nests once per input object, so the object reader has to count the levels
+      // itself -- nothing is skipped along the way.
+      list_node shallow_out{};
+      expect(not glz::read_beve(shallow_out, nested_list(100))) << "a list within the limit must read";
+
+      list_node deep_out{};
+      expect(glz::read_beve(deep_out, nested_list(glz::max_recursive_depth_limit + 50)) ==
+             glz::error_code::exceeded_max_recursive_depth);
+   };
+
+   "nested generic arrays are bounded for typed containers too"_test = [] {
+      // Each tree_node level is two wire levels, the object and the array its member holds, so the
+      // limit is reached at half the struct depth. That is the accounting the JSON reader uses.
+      tree_node shallow_out{};
+      expect(not glz::read_beve(shallow_out, nested_tree(100)));
+
+      tree_node deep_out{};
+      expect(glz::read_beve(deep_out, nested_tree(glz::max_recursive_depth_limit)) ==
+             glz::error_code::exceeded_max_recursive_depth);
+   };
+
+   "an over-nested buffer is reported as such, not as a failed variant match"_test = [] {
+      // Every alternative of a variant (glz::generic among them) fails on input this deep, and the
+      // depth is the honest diagnosis, so it has to survive the resolution loop rather than be
+      // reported as no_matching_variant_type.
+      std::string nest;
+      for (size_t i = 0; i < glz::max_recursive_depth_limit + 50; ++i) {
+         nest.push_back(char(glz::tag::generic_array));
+         nest.push_back(char(1 << 2));
+      }
+      nest.push_back(char(glz::tag::null));
+
+      glz::generic out{};
+      expect(glz::read_beve(out, nest) == glz::error_code::exceeded_max_recursive_depth);
+   };
+
+   "an ambiguous nest cannot multiply the work of resolving it"_test = [] {
+      // Resolution is speculative: an alternative is parsed to find out whether it fits, and a
+      // rejected one is rewound and the next tried. Nest that and the re-parses multiply -- measured
+      // at ~4.3x per level, so 189 bytes took 55 seconds and 256 levels would never return. The
+      // speculation budget caps the total re-parsed bytes, so the cost stops growing with depth
+      // (~8 ms here, whatever the nesting). Timed rather than asserted on the error alone: a
+      // reversion is a hang, and a hung suite is a worse signal than a failed expectation.
+      const auto start = std::chrono::steady_clock::now();
+      for (size_t levels : {4u, 8u, 16u, 32u}) {
+         amb_v out{};
+         expect(bool(glz::read_beve(out, ambiguous_nest(levels)))) << "levels=" << levels;
+      }
+      const auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
+      expect(ms < 5000.0) << "resolving ambiguous nests took " << ms << " ms";
+   };
+
+   "the budget does not penalise many variants side by side"_test = [] {
+      // The bound is on re-parsed bytes relative to the input, so width costs the same per element
+      // however wide the document is: each element rejects the first alternative once.
+      std::string buffer = "[";
+      for (size_t i = 0; i < 50'000; ++i) {
+         if (i) buffer += ',';
+         buffer += R"({"b":1})";
+      }
+      buffer += "]";
+
+      std::vector<std::variant<beve_depth::alt_a, beve_depth::alt_b>> out{};
+      const auto ec = glz::read_json(out, buffer); // JSON: same resolution machinery, readable inline
+      expect(not ec) << glz::format_error(ec, buffer);
+      expect(out.size() == 50'000);
+   };
+
+   "a bogus element count cannot spin a set read"_test = [] {
+      // The count is attacker controlled and capped only at 2^48; without a bound against the
+      // remaining input the loop errors on every element and keeps going for hours.
+      std::string buffer;
+      buffer.push_back(char(glz::tag::generic_array));
+      const uint64_t count = (uint64_t{1} << 48) - 1;
+      const uint64_t compressed = (count << 2) | 0b11; // 8 byte compressed count
+      for (int i = 0; i < 8; ++i) {
+         buffer.push_back(char(uint8_t(compressed >> (8 * i))));
+      }
+
+      std::set<std::vector<int>> out{};
+      expect(glz::read_beve(out, buffer) == glz::error_code::invalid_length);
+   };
+};
+
 int main()
 {
    trace.begin("binary_test");
diff --git a/tests/cbor_test/cbor_test.cpp b/tests/cbor_test/cbor_test.cpp
index 0ae4f6e2c4..69d22c60eb 100644
--- a/tests/cbor_test/cbor_test.cpp
+++ b/tests/cbor_test/cbor_test.cpp
@@ -3594,6 +3594,72 @@ suite cbor_shrink_to_fit_tests = [] {
    };
 };
 
+namespace cbor_depth
+{
+   struct one_field
+   {
+      int a{};
+   };
+
+   struct tree_node
+   {
+      std::vector<tree_node> children{};
+   };
+
+   // { "zz": [[[ ... ]]] } with indefinite-length arrays: one byte per nesting level, and "zz" is not
+   // a member of the target struct, so a lax read skips the whole nest.
+   inline std::string nested_arrays(size_t levels)
+   {
+      std::string b;
+      b.push_back(char(0xA1)); // map(1)
+      b.push_back(char(0x62)); // text(2)
+      b += "zz";
+      for (size_t i = 0; i < levels; ++i) {
+         b.push_back(char(0x9F)); // indefinite array
+      }
+      b.push_back(char(0xF6)); // null
+      for (size_t i = 0; i < levels; ++i) {
+         b.push_back(char(0xFF)); // break
+      }
+      return b;
+   }
+
+   inline std::string nested_tree(size_t levels)
+   {
+      tree_node root{};
+      auto* cur = &root;
+      for (size_t i = 0; i < levels; ++i) {
+         cur->children.emplace_back();
+         cur = &cur->children.front();
+      }
+      return glz::write_cbor(root).value();
+   }
+}
+
+suite cbor_recursion_depth_limit = [] {
+   using namespace cbor_depth;
+
+   "a hostile nest of arrays is rejected rather than overflowing the stack"_test = [] {
+      // One byte per level: 200k levels is 400 KB of input against a default 8 MB stack (1 MB on
+      // Windows). The skip must stop at max_recursive_depth_limit instead of recursing to a crash.
+      const auto buffer = nested_arrays(200'000);
+
+      constexpr glz::opts lax{.format = glz::CBOR, .error_on_unknown_keys = false};
+      one_field out{};
+      expect(glz::read<lax>(out, buffer) == glz::error_code::exceeded_max_recursive_depth);
+   };
+
+   "the limit binds the typed readers, not just skipping"_test = [] {
+      // Each tree_node level is two wire levels, the map and the array its member holds.
+      tree_node shallow_out{};
+      expect(not glz::read_cbor(shallow_out, nested_tree(100)));
+
+      tree_node deep_out{};
+      expect(glz::read_cbor(deep_out, nested_tree(glz::max_recursive_depth_limit)) ==
+             glz::error_code::exceeded_max_recursive_depth);
+   };
+};
+
 int main()
 {
    custom_variant_ambiguity_tests();
diff --git a/tests/json_test/json_test.cpp b/tests/json_test/json_test.cpp
index e45ae0ada8..6f2fb41e2e 100644
--- a/tests/json_test/json_test.cpp
+++ b/tests/json_test/json_test.cpp
@@ -9,6 +9,7 @@
 #include <chrono>
 #include <complex>
 #include <deque>
+#include <expected>
 #include <forward_list>
 #include <initializer_list>
 #include <iostream>
@@ -15212,6 +15213,209 @@ suite bounded_buffer_overflow_tests = [] {
    };
 };
 
+namespace json_depth
+{
+   struct list_node
+   {
+      std::unique_ptr<list_node> next{};
+      int n{};
+   };
+
+   struct one_field
+   {
+      int a{};
+   };
+
+   struct alt_a
+   {
+      int a{};
+   };
+   struct alt_b
+   {
+      int b{};
+   };
+
+   inline std::string nested_objects(size_t levels, std::string_view key = "next")
+   {
+      std::string b;
+      for (size_t i = 0; i < levels; ++i) {
+         b += "{\"";
+         b += key;
+         b += "\":";
+      }
+      b += "null";
+      b.append(levels, '}');
+      return b;
+   }
+
+   // Two array alternatives, so the variant is not auto-deducible and each level tries both.
+   struct node;
+   using two_arrays = std::variant<std::vector<node>, std::list<node>>;
+   struct node
+   {
+      two_arrays child{};
+   };
+
+   inline std::string nested_arrays(size_t levels)
+   {
+      std::string b;
+      b.append(levels, '[');
+      b.append(levels, ']');
+      return b;
+   }
+}
+
+suite json_recursion_depth_limit = [] {
+   using namespace json_depth;
+
+   "a recursive type is bounded by the depth limit, not by the stack"_test = [] {
+      // Nesting is driven by the input, not by the type, so the readers have to count it. 100k levels
+      // of "{\"next\":" is 900 KB against a default 8 MB stack (1 MB on Windows).
+      list_node deep_out{};
+      expect(glz::read_json(deep_out, nested_objects(100'000)) == glz::error_code::exceeded_max_recursive_depth);
+
+      list_node ok_out{};
+      expect(not glz::read_json(ok_out, nested_objects(100))) << "nesting within the limit must still read";
+   };
+
+   "the limit binds at max_recursive_depth_limit levels"_test = [] {
+      list_node at_limit{};
+      expect(not glz::read_json(at_limit, nested_objects(glz::max_recursive_depth_limit)));
+
+      list_node past_limit{};
+      expect(glz::read_json(past_limit, nested_objects(glz::max_recursive_depth_limit + 1)) ==
+             glz::error_code::exceeded_max_recursive_depth);
+   };
+
+   "untyped reads are bounded too"_test = [] {
+      glz::generic out{};
+      expect(glz::read_json(out, nested_arrays(100'000)) == glz::error_code::exceeded_max_recursive_depth);
+   };
+
+   "a validated skip of an unknown key is bounded"_test = [] {
+      // The default skip walks the nest iteratively; validate_skipped parses it, which recurses.
+      struct validating : glz::opts
+      {
+         bool validate_skipped = true;
+      };
+      static constexpr validating opts{{glz::opts{.error_on_unknown_keys = false}}};
+
+      std::string buffer = R"({"zz":)";
+      buffer += nested_arrays(100'000);
+      buffer += "}";
+
+      one_field out{};
+      expect(glz::read<opts>(out, buffer) == glz::error_code::exceeded_max_recursive_depth);
+   };
+
+   "an over-nested variant errors instead of retrying every alternative"_test = [] {
+      // Two array alternatives make this variant non-auto-deducible, so each level tries both. Past
+      // the limit every level fails identically, and a retry that rewound the depth handed the next
+      // alternative a fresh budget to re-descend on: N^depth work for a 1.5 KB buffer. The limit is
+      // reached at 128 levels here because the array and the object each count. This must error
+      // immediately, so a regression shows up as a hung test rather than a failed assertion.
+      const auto build = [](size_t levels) {
+         std::string b;
+         for (size_t i = 0; i < levels; ++i) b += R"([{"child":)";
+         b += "[]";
+         for (size_t i = 0; i < levels; ++i) b += "}]";
+         return b;
+      };
+
+      two_arrays under{};
+      expect(not glz::read_json(under, build(glz::max_recursive_depth_limit / 2 - 1)));
+
+      two_arrays over{};
+      expect(glz::read_json(over, build(glz::max_recursive_depth_limit / 2)) ==
+             glz::error_code::exceeded_max_recursive_depth);
+
+      two_arrays far_over{};
+      expect(glz::read_json(far_over, build(5000)) == glz::error_code::exceeded_max_recursive_depth);
+   };
+
+   "std::expected does not spend a level per value"_test = [] {
+      // The wrapper reader holds a level while it scans for the "unexpected" key, and every path has
+      // to give it back -- the two that rewind and re-read the object through a counting reader
+      // included. Leaking it capped a run of wrappers at 256.
+      //
+      // Reads share one context rather than filling a std::vector<std::expected<...>>, which is the
+      // same test but instantiates vector's iterator comparison against std::expected; libc++ on the
+      // P2996 clang recurses into its own operator== constraint there and fails to compile.
+      glz::context ctx{};
+      for (size_t i = 0; i < 2 * glz::max_recursive_depth_limit; ++i) {
+         std::expected<one_field, std::string> out{};
+         const auto ec = glz::read<glz::opts{}>(out, R"({"a":1})", ctx);
+         expect(not ec) << "read " << i << ": " << glz::format_error(ec);
+         if (ec) break;
+      }
+      expect(ctx.depth == 0) << "the reader must leave the depth balanced: " << ctx.depth;
+
+      // Holding the level on the error paths is what still catches a truncated wrapper when the
+      // buffer is not null terminated.
+      static constexpr glz::opts nnt{.null_terminated = false};
+      for (const std::string_view prefix : {R"({"unexpected")", R"({"unexpected":"boom")"}) {
+         std::vector<char> buf{prefix.begin(), prefix.end()};
+         const std::string_view view{buf.data(), buf.data() + buf.size()};
+         std::expected<one_field, std::string> trunc{};
+         expect(bool(glz::read<nnt>(trunc, view))) << "truncated wrapper must not succeed: " << prefix;
+      }
+   };
+
+   "partial_read leaves the depth balanced"_test = [] {
+      // A partial read stops on a success path, so it has to close its level like any other exit;
+      // otherwise the count creeps up across reads that share a context.
+      static constexpr glz::opts partial{.partial_read = true};
+
+      glz::context ctx{};
+      for (size_t i = 0; i < 2 * glz::max_recursive_depth_limit; ++i) {
+         one_field out{};
+         const auto ec = glz::read<partial>(out, R"({"a":1,"zz":2})", ctx);
+         expect(not ec) << "read " << i << ": " << glz::format_error(ec);
+         if (ec) break;
+      }
+      expect(ctx.depth == 0) << ctx.depth;
+   };
+
+   "an ambiguous nest cannot multiply the work of resolving it"_test = [] {
+      // The JSON analogue of the BEVE cascade: two array alternatives, so every level tries both,
+      // with a malformed leaf at the bottom that every level retries. 339 bytes took 40 seconds
+      // before the speculation budget capped the total re-parsed bytes; the cost no longer grows
+      // with depth.
+      const auto build = [](size_t levels) {
+         std::string b;
+         for (size_t i = 0; i < levels; ++i) b += R"([{"child":)";
+         b += "[{]"; // malformed leaf
+         for (size_t i = 0; i < levels; ++i) b += "}]";
+         return b;
+      };
+
+      const auto start = std::chrono::steady_clock::now();
+      for (size_t levels : {8u, 16u, 28u, 36u}) {
+         two_arrays out{};
+         expect(bool(glz::read_json(out, build(levels)))) << "levels=" << levels;
+      }
+      const auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
+      expect(ms < 5000.0) << "resolving ambiguous nests took " << ms << " ms";
+   };
+
+   "rejected variant alternatives do not spend the depth budget"_test = [] {
+      // Each element tries alt_a first and fails inside its object, which leaves that level counted
+      // until the alternative is rewound. Without the rewind this errors once 256 elements have been
+      // attempted, even though the document is two levels deep.
+      std::string buffer = "[";
+      for (size_t i = 0; i < 2 * glz::max_recursive_depth_limit; ++i) {
+         if (i) buffer += ',';
+         buffer += R"({"b":1})";
+      }
+      buffer += "]";
+
+      std::vector<std::variant<alt_a, alt_b>> out{};
+      const auto ec = glz::read<glz::opts{.error_on_unknown_keys = false}>(out, buffer);
+      expect(not ec) << glz::format_error(ec, buffer);
+      expect(out.size() == 2 * glz::max_recursive_depth_limit);
+   };
+};
+
 int main()
 {
    trace.end("json_test");
diff --git a/tests/msgpack_test/msgpack_test.cpp b/tests/msgpack_test/msgpack_test.cpp
index 8280f0b0c1..e30291cb77 100644
--- a/tests/msgpack_test/msgpack_test.cpp
+++ b/tests/msgpack_test/msgpack_test.cpp
@@ -605,6 +605,51 @@ suite short_ids_write_guard = [] {
    };
 };
 
+namespace msgpack_depth
+{
+   struct tree_node
+   {
+      std::vector<tree_node> children{};
+   };
+
+   // Structs are written as arrays, so every level is fixarray(1) for the struct plus fixarray(1)
+   // for its member: two bytes of input per level of nesting.
+   inline std::string nested_tree(size_t levels)
+   {
+      std::string b;
+      for (size_t i = 0; i < levels; ++i) {
+         b.push_back(char(0x91));
+         b.push_back(char(0x91));
+      }
+      b.push_back(char(0x91));
+      b.push_back(char(0x90)); // innermost node, no children
+      return b;
+   }
+}
+
+suite msgpack_recursion_depth_limit = [] {
+   using namespace msgpack_depth;
+
+   "a hostile nest is rejected rather than overflowing the stack"_test = [] {
+      // 100k levels is 200 KB of input against a default 8 MB stack (1 MB on Windows).
+      tree_node deep_out{};
+      expect(glz::read_msgpack(deep_out, nested_tree(100'000)) == glz::error_code::exceeded_max_recursive_depth);
+   };
+
+   "nesting within the limit still reads"_test = [] {
+      tree_node shallow_out{};
+      expect(not glz::read_msgpack(shallow_out, nested_tree(100)));
+
+      tree_node* cur = &shallow_out;
+      size_t levels = 0;
+      while (not cur->children.empty()) {
+         cur = &cur->children.front();
+         ++levels;
+      }
+      expect(levels == 100) << levels;
+   };
+};
+
 int main()
 {
    // Only the write side is exercised here: MessagePack passes its type-tag byte