diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go
index 45845ea15..40ae3a295 100644
--- a/modules/caddyhttp/app.go
+++ b/modules/caddyhttp/app.go
@@ -47,6 +47,7 @@ func init() {
 //
 // Placeholder | Description
 // ------------|---------------
+// `{http.request.body}` | The request body (⚠️ inefficient; use only for debugging)
 // `{http.request.cookie.*}` | HTTP request cookie
 // `{http.request.header.*}` | Specific request header field
 // `{http.request.host.labels.*}` | Request host labels (0-based from right); e.g. for foo.example.com: 0=com, 1=example, 2=foo
diff --git a/modules/caddyhttp/replacer.go b/modules/caddyhttp/replacer.go
index f59502114..3f4a808e6 100644
--- a/modules/caddyhttp/replacer.go
+++ b/modules/caddyhttp/replacer.go
@@ -15,6 +15,7 @@
 package caddyhttp
 
 import (
+	"bytes"
 	"context"
 	"crypto/ecdsa"
 	"crypto/ed25519"
@@ -25,6 +26,8 @@ import (
 	"crypto/x509"
 	"encoding/asn1"
 	"fmt"
+	"io"
+	"io/ioutil"
 	"net"
 	"net/http"
 	"net/textproto"
@@ -136,6 +139,24 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
 				return dir, true
 			case "http.request.uri.query":
 				return req.URL.RawQuery, true
+			case "http.request.body":
+				if req.Body == nil {
+					return "", true
+				}
+				// normally net/http will close the body for us, but since we
+				// are replacing it with a fake one, we have to ensure we close
+				// the real body ourselves when we're done
+				defer req.Body.Close()
+				// read the request body into a buffer (can't pool because we
+				// don't know its lifetime and would have to make a copy anyway)
+				buf := new(bytes.Buffer)
+				_, err := io.Copy(buf, req.Body)
+				if err != nil {
+					return "", true
+				}
+				// replace real body with buffered data
+				req.Body = ioutil.NopCloser(buf)
+				return buf.String(), true
 
 				// original request, before any internal changes
 			case "http.request.orig_method":