diff --git a/CHANGELOG.md b/CHANGELOG.md
index add4b3e9..25949716 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,14 +23,23 @@ See also the [v0.107.23 GitHub milestone][ms-v0.107.23].
 NOTE: Add new changes BELOW THIS COMMENT.
 -->
 
+### Added
+
+- DNS64 support ([#5117]).  The function may be enabled with new `use_dns64`
+  field under `dns` object in the configuration along with `dns64_prefixes`, the
+  set of exclusion prefixes to filter AAAA responses.  The Well-Known Prefix
+  (`64:ff9b::/96`) is used if no custom prefixes are specified.
+
 ### Removed
 
- *  The “beta frontend” and the corresponding APIs.  They never quite worked
-    properly, and the future new version of AdGuard Home API will probably be
-    different.
+- The “beta frontend” and the corresponding APIs.  They never quite worked
+  properly, and the future new version of AdGuard Home API will probably be
+  different.
 
-    Correspondingly, the configuration parameter `beta_bind_port` has been
-    removed as well.
+  Correspondingly, the configuration parameter `beta_bind_port` has been removed
+  as well.
+
+[#5117]: https://github.com/AdguardTeam/AdGuardHome/issues/5117
 
 
 
diff --git a/internal/dnsforward/config.go b/internal/dnsforward/config.go
index 6357d681..c30d0d89 100644
--- a/internal/dnsforward/config.go
+++ b/internal/dnsforward/config.go
@@ -224,6 +224,9 @@ type ServerConfig struct {
 	// resolving PTR queries for local addresses.
 	LocalPTRResolvers []string
 
+	// DNS64Prefixes is a slice of NAT64 prefixes to be used for DNS64.
+	DNS64Prefixes []string
+
 	// ResolveClients signals if the RDNS should resolve clients' addresses.
 	ResolveClients bool
 
@@ -231,6 +234,9 @@ type ServerConfig struct {
 	// locally-served networks should be resolved via private PTR resolvers.
 	UsePrivateRDNS bool
 
+	// UseDNS64 defines if DNS64 is enabled for incoming requests.
+	UseDNS64 bool
+
 	// ServeHTTP3 defines if HTTP/3 is be allowed for incoming requests.
 	ServeHTTP3 bool
 
diff --git a/internal/dnsforward/dns.go b/internal/dnsforward/dns.go
index e4ca6520..1411a0f4 100644
--- a/internal/dnsforward/dns.go
+++ b/internal/dnsforward/dns.go
@@ -28,9 +28,10 @@ type dnsContext struct {
 	// response is modified by filters.
 	origResp *dns.Msg
 
-	// unreversedReqIP stores an IP address obtained from PTR request if it
-	// parsed successfully and belongs to one of locally-served IP ranges as per
-	// RFC 6303.
+	// unreversedReqIP stores an IP address obtained from a PTR request if it
+	// was parsed successfully and belongs to one of the locally served IP
+	// ranges.  It is also filled with unmapped version of the address if it's
+	// within DNS64 prefixes.
 	unreversedReqIP net.IP
 
 	// err is the error returned from a processing function.
@@ -57,7 +58,7 @@ type dnsContext struct {
 	// responseAD shows if the response had the AD bit set.
 	responseAD bool
 
-	// isLocalClient shows if client's IP address is from locally-served
+	// isLocalClient shows if client's IP address is from locally served
 	// network.
 	isLocalClient bool
 }
@@ -133,8 +134,8 @@ func (s *Server) handleDNSRequest(_ *proxy.Proxy, pctx *proxy.DNSContext) error
 	return nil
 }
 
-// processRecursion checks the incoming request and halts it's handling if s
-// have tried to resolve it recently.
+// processRecursion checks the incoming request and halts its handling by
+// answering NXDOMAIN if s has tried to resolve it recently.
 func (s *Server) processRecursion(dctx *dnsContext) (rc resultCode) {
 	pctx := dctx.proxyCtx
 
@@ -349,8 +350,8 @@ func (s *Server) makeDDRResponse(req *dns.Msg) (resp *dns.Msg) {
 	return resp
 }
 
-// processDetermineLocal determines if the client's IP address is from
-// locally-served network and saves the result into the context.
+// processDetermineLocal determines if the client's IP address is from locally
+// served network and saves the result into the context.
 func (s *Server) processDetermineLocal(dctx *dnsContext) (rc resultCode) {
 	rc = resultCodeSuccess
 
@@ -377,7 +378,8 @@ func (s *Server) dhcpHostToIP(host string) (ip netip.Addr, ok bool) {
 }
 
 // processDHCPHosts respond to A requests if the target hostname is known to
-// the server.
+// the server.  It responds with a mapped IP address if the DNS64 is enabled and
+// the request is for AAAA.
 //
 // TODO(a.garipov): Adapt to AAAA as well.
 func (s *Server) processDHCPHosts(dctx *dnsContext) (rc resultCode) {
@@ -409,20 +411,34 @@ func (s *Server) processDHCPHosts(dctx *dnsContext) (rc resultCode) {
 	log.Debug("dnsforward: dhcp record for %q is %s", reqHost, ip)
 
 	resp := s.makeResponse(req)
-	if q.Qtype == dns.TypeA {
+	switch q.Qtype {
+	case dns.TypeA:
 		a := &dns.A{
 			Hdr: s.hdr(req, dns.TypeA),
 			A:   ip.AsSlice(),
 		}
 		resp.Answer = append(resp.Answer, a)
+	case dns.TypeAAAA:
+		if len(s.dns64Prefs) > 0 {
+			// Respond with DNS64-mapped address for IPv4 host if DNS64 is
+			// enabled.
+			aaaa := &dns.AAAA{
+				Hdr:  s.hdr(req, dns.TypeAAAA),
+				AAAA: s.mapDNS64(ip),
+			}
+			resp.Answer = append(resp.Answer, aaaa)
+		}
+	default:
+		// Go on.
 	}
+
 	dctx.proxyCtx.Res = resp
 
 	return resultCodeSuccess
 }
 
 // processRestrictLocal responds with NXDOMAIN to PTR requests for IP addresses
-// in locally-served network from external clients.
+// in locally served network from external clients.
 func (s *Server) processRestrictLocal(dctx *dnsContext) (rc resultCode) {
 	pctx := dctx.proxyCtx
 	req := pctx.Req
@@ -452,15 +468,24 @@ func (s *Server) processRestrictLocal(dctx *dnsContext) (rc resultCode) {
 		return resultCodeSuccess
 	}
 
-	// Restrict an access to local addresses for external clients.  We also
-	// assume that all the DHCP leases we give are locally-served or at least
-	// don't need to be accessible externally.
-	if !s.privateNets.Contains(ip) {
-		log.Debug("dnsforward: addr %s is not from locally-served network", ip)
+	if s.shouldStripDNS64(ip) {
+		// Strip the prefix from the address to get the original IPv4.
+		ip = ip[nat64PrefixLen:]
 
+		// Treat a DNS64-prefixed address as a locally served one since those
+		// queries should never be sent to the global DNS.
+		dctx.unreversedReqIP = ip
+	}
+
+	// Restrict an access to local addresses for external clients.  We also
+	// assume that all the DHCP leases we give are locally served or at least
+	// shouldn't be accessible externally.
+	if !s.privateNets.Contains(ip) {
 		return resultCodeSuccess
 	}
 
+	log.Debug("dnsforward: addr %s is from locally served network", ip)
+
 	if !dctx.isLocalClient {
 		log.Debug("dnsforward: %q requests an internal ip", pctx.Addr)
 		pctx.Res = s.genNXDomain(req)
@@ -473,7 +498,7 @@ func (s *Server) processRestrictLocal(dctx *dnsContext) (rc resultCode) {
 	dctx.unreversedReqIP = ip
 
 	// There is no need to filter request from external addresses since this
-	// code is only executed when the request is for locally-served ARPA
+	// code is only executed when the request is for locally served ARPA
 	// hostname so disable redundant filters.
 	dctx.setts.ParentalEnabled = false
 	dctx.setts.SafeBrowsingEnabled = false
@@ -508,7 +533,7 @@ func (s *Server) processDHCPAddrs(dctx *dnsContext) (rc resultCode) {
 		return resultCodeSuccess
 	}
 
-	// TODO(a.garipov):  Remove once we switch to netip.Addr more fully.
+	// TODO(a.garipov):  Remove once we switch to [netip.Addr] more fully.
 	ipAddr, err := netutil.IPToAddrNoMapped(ip)
 	if err != nil {
 		log.Debug("dnsforward: bad reverse ip %v from dhcp: %s", ip, err)
@@ -556,10 +581,6 @@ func (s *Server) processLocalPTR(dctx *dnsContext) (rc resultCode) {
 	s.serverLock.RLock()
 	defer s.serverLock.RUnlock()
 
-	if !s.privateNets.Contains(ip) {
-		return resultCodeSuccess
-	}
-
 	if s.conf.UsePrivateRDNS {
 		s.recDetector.add(*pctx.Req)
 		if err := s.localResolvers.Resolve(pctx); err != nil {
@@ -636,9 +657,8 @@ func (s *Server) processUpstream(dctx *dnsContext) (rc resultCode) {
 
 	origReqAD := false
 	if s.conf.EnableDNSSEC {
-		if req.AuthenticatedData {
-			origReqAD = true
-		} else {
+		origReqAD = req.AuthenticatedData
+		if !req.AuthenticatedData {
 			req.AuthenticatedData = true
 		}
 	}
@@ -655,6 +675,10 @@ func (s *Server) processUpstream(dctx *dnsContext) (rc resultCode) {
 		return resultCodeError
 	}
 
+	if s.performDNS64(prx, dctx) == resultCodeError {
+		return resultCodeError
+	}
+
 	dctx.responseFromUpstream = true
 	dctx.responseAD = pctx.Res.AuthenticatedData
 
diff --git a/internal/dnsforward/dns64.go b/internal/dnsforward/dns64.go
new file mode 100644
index 00000000..28afb8cc
--- /dev/null
+++ b/internal/dnsforward/dns64.go
@@ -0,0 +1,349 @@
+package dnsforward
+
+import (
+	"fmt"
+	"net"
+	"net/netip"
+
+	"github.com/AdguardTeam/dnsproxy/proxy"
+	"github.com/AdguardTeam/golibs/log"
+	"github.com/AdguardTeam/golibs/netutil"
+	"github.com/miekg/dns"
+)
+
+const (
+	// maxNAT64PrefixBitLen is the maximum length of a NAT64 prefix in bits.
+	// See https://datatracker.ietf.org/doc/html/rfc6147#section-5.2.
+	maxNAT64PrefixBitLen = 96
+
+	// nat64PrefixLen is the length of a NAT64 prefix in bytes.
+	nat64PrefixLen = net.IPv6len - net.IPv4len
+
+	// maxDNS64SynTTL is the maximum TTL for synthesized DNS64 responses with no
+	// SOA records in seconds.
+	//
+	// If the SOA RR was not delivered with the negative response to the AAAA
+	// query, then the DNS64 SHOULD use the TTL of the original A RR or 600
+	// seconds, whichever is shorter.
+	//
+	// See https://datatracker.ietf.org/doc/html/rfc6147#section-5.1.7.
+	maxDNS64SynTTL uint32 = 600
+)
+
+// setupDNS64 initializes DNS64 settings, the NAT64 prefixes in particular.  If
+// the DNS64 feature is enabled and no prefixes are configured, the default
+// Well-Known Prefix is used, just like Section 5.2 of RFC 6147 prescribes.  Any
+// configured set of prefixes discards the default Well-Known prefix unless it
+// is specified explicitly.  Each prefix also validated to be a valid IPv6
+// CIDR with a maximum length of 96 bits.  The first specified prefix is then
+// used to synthesize AAAA records.
+func (s *Server) setupDNS64() (err error) {
+	if !s.conf.UseDNS64 {
+		return nil
+	}
+
+	l := len(s.conf.DNS64Prefixes)
+	if l == 0 {
+		s.dns64Prefs = []netip.Prefix{dns64WellKnownPref}
+
+		return nil
+	}
+
+	prefs := make([]netip.Prefix, 0, l)
+	for i, pref := range s.conf.DNS64Prefixes {
+		var p netip.Prefix
+		p, err = netip.ParsePrefix(pref)
+		if err != nil {
+			return fmt.Errorf("prefix at index %d: %w", i, err)
+		}
+
+		addr := p.Addr()
+		if !addr.Is6() {
+			return fmt.Errorf("prefix at index %d: %q is not an IPv6 prefix", i, pref)
+		}
+
+		if p.Bits() > maxNAT64PrefixBitLen {
+			return fmt.Errorf("prefix at index %d: %q is too long for DNS64", i, pref)
+		}
+
+		prefs = append(prefs, p.Masked())
+	}
+
+	s.dns64Prefs = prefs
+
+	return nil
+}
+
+// checkDNS64 checks if DNS64 should be performed.  It returns a DNS64 request
+// to resolve or nil if DNS64 is not desired.  It also filters resp to not
+// contain any NAT64 excluded addresses in the answer section, if needed.  Both
+// req and resp must not be nil.
+//
+// See https://datatracker.ietf.org/doc/html/rfc6147.
+func (s *Server) checkDNS64(req, resp *dns.Msg) (dns64Req *dns.Msg) {
+	if len(s.dns64Prefs) == 0 {
+		return nil
+	}
+
+	q := req.Question[0]
+	if q.Qtype != dns.TypeAAAA || q.Qclass != dns.ClassINET {
+		// DNS64 operation for classes other than IN is undefined, and a DNS64
+		// MUST behave as though no DNS64 function is configured.
+		return nil
+	}
+
+	rcode := resp.Rcode
+	if rcode == dns.RcodeNameError {
+		// A result with RCODE=3 (Name Error) is handled according to normal DNS
+		// operation (which is normally to return the error to the client).
+		return nil
+	}
+
+	if rcode == dns.RcodeSuccess {
+		// If resolver receives an answer with at least one AAAA record
+		// containing an address outside any of the excluded range(s), then it
+		// by default SHOULD build an answer section for a response including
+		// only the AAAA record(s) that do not contain any of the addresses
+		// inside the excluded ranges.
+		var hasAnswers bool
+		if resp.Answer, hasAnswers = s.filterNAT64Answers(resp.Answer); hasAnswers {
+			return nil
+		}
+
+		// Any other RCODE is treated as though the RCODE were 0 and the answer
+		// section were empty.
+	}
+
+	return &dns.Msg{
+		MsgHdr: dns.MsgHdr{
+			Id:                dns.Id(),
+			RecursionDesired:  req.RecursionDesired,
+			AuthenticatedData: req.AuthenticatedData,
+			CheckingDisabled:  req.CheckingDisabled,
+		},
+		Question: []dns.Question{{
+			Name:   req.Question[0].Name,
+			Qtype:  dns.TypeA,
+			Qclass: dns.ClassINET,
+		}},
+	}
+}
+
+// filterNAT64Answers filters out AAAA records that are within one of NAT64
+// exclusion prefixes.  hasAnswers is true if the filtered slice contains at
+// least a single AAAA answer not within the prefixes or a CNAME.
+func (s *Server) filterNAT64Answers(rrs []dns.RR) (filtered []dns.RR, hasAnswers bool) {
+	filtered = make([]dns.RR, 0, len(rrs))
+	for _, ans := range rrs {
+		switch ans := ans.(type) {
+		case *dns.AAAA:
+			addr, err := netutil.IPToAddrNoMapped(ans.AAAA)
+			if err != nil {
+				log.Error("dnsforward: bad AAAA record: %s", err)
+
+				continue
+			}
+
+			if s.withinDNS64(addr) {
+				// Filter the record.
+				continue
+			}
+
+			filtered, hasAnswers = append(filtered, ans), true
+		case *dns.CNAME, *dns.DNAME:
+			// If the response contains a CNAME or a DNAME, then the CNAME or
+			// DNAME chain is followed until the first terminating A or AAAA
+			// record is reached.
+			//
+			// Just treat CNAME and DNAME responses as passable answers since
+			// AdGuard Home doesn't follow any of these chains except the
+			// dnsrewrite-defined ones.
+			filtered, hasAnswers = append(filtered, ans), true
+		default:
+			filtered = append(filtered, ans)
+		}
+	}
+
+	return filtered, hasAnswers
+}
+
+// synthDNS64 synthesizes a DNS64 response using the original response as a
+// basis and modifying it with data from resp.  It returns true if the response
+// was actually modified.
+func (s *Server) synthDNS64(origReq, origResp, resp *dns.Msg) (ok bool) {
+	if len(resp.Answer) == 0 {
+		// If there is an empty answer, then the DNS64 responds to the original
+		// querying client with the answer the DNS64 received to the original
+		// (initiator's) query.
+		return false
+	}
+
+	// The Time to Live (TTL) field is set to the minimum of the TTL of the
+	// original A RR and the SOA RR for the queried domain.  If the original
+	// response contains no SOA records, the minimum of the TTL of the original
+	// A RR and [maxDNS64SynTTL] should be used.  See [maxDNS64SynTTL].
+	soaTTL := maxDNS64SynTTL
+	for _, rr := range origResp.Ns {
+		if hdr := rr.Header(); hdr.Rrtype == dns.TypeSOA && hdr.Name == origReq.Question[0].Name {
+			soaTTL = hdr.Ttl
+
+			break
+		}
+	}
+
+	newAns := make([]dns.RR, 0, len(resp.Answer))
+	for _, ans := range resp.Answer {
+		rr := s.synthRR(ans, soaTTL)
+		if rr == nil {
+			// The error should have already been logged.
+			return false
+		}
+
+		newAns = append(newAns, rr)
+	}
+
+	origResp.Answer = newAns
+	origResp.Ns = resp.Ns
+	origResp.Extra = resp.Extra
+
+	return true
+}
+
+// dns64WellKnownPref is the default prefix to use in an algorithmic mapping for
+// DNS64.  See https://datatracker.ietf.org/doc/html/rfc6052#section-2.1.
+var dns64WellKnownPref = netip.MustParsePrefix("64:ff9b::/96")
+
+// withinDNS64 checks if ip is within one of the configured DNS64 prefixes.
+//
+// TODO(e.burkov):  We actually using bytes of only the first prefix from the
+// set to construct the answer, so consider using some implementation of a
+// prefix set for the rest.
+func (s *Server) withinDNS64(ip netip.Addr) (ok bool) {
+	for _, n := range s.dns64Prefs {
+		if n.Contains(ip) {
+			return true
+		}
+	}
+
+	return false
+}
+
+// shouldStripDNS64 returns true if DNS64 is enabled and ip has either one of
+// custom DNS64 prefixes or the Well-Known one.  This is intended to be used
+// with PTR requests.
+//
+// The requirement is to match any Pref64::/n used at the site, and not merely
+// the locally configured Pref64::/n.  This is because end clients could ask for
+// a PTR record matching an address received through a different (site-provided)
+// DNS64.
+//
+// See https://datatracker.ietf.org/doc/html/rfc6147#section-5.3.1.
+func (s *Server) shouldStripDNS64(ip net.IP) (ok bool) {
+	if len(s.dns64Prefs) == 0 {
+		return false
+	}
+
+	addr, err := netutil.IPToAddr(ip, netutil.AddrFamilyIPv6)
+	if err != nil {
+		return false
+	}
+
+	switch {
+	case s.withinDNS64(addr):
+		log.Debug("dnsforward: %s is within DNS64 custom prefix set", ip)
+	case dns64WellKnownPref.Contains(addr):
+		log.Debug("dnsforward: %s is within DNS64 well-known prefix", ip)
+	default:
+		return false
+	}
+
+	return true
+}
+
+// mapDNS64 maps ip to IPv6 address using configured DNS64 prefix.  ip must be a
+// valid IPv4.  It panics, if there are no configured DNS64 prefixes, because
+// synthesis should not be performed unless DNS64 function enabled.
+func (s *Server) mapDNS64(ip netip.Addr) (mapped net.IP) {
+	// Don't mask the address here since it should have already been masked on
+	// initialization stage.
+	pref := s.dns64Prefs[0].Addr().As16()
+	ipData := ip.As4()
+
+	mapped = make(net.IP, net.IPv6len)
+	copy(mapped[:nat64PrefixLen], pref[:])
+	copy(mapped[nat64PrefixLen:], ipData[:])
+
+	return mapped
+}
+
+// performDNS64 processes the current state of dctx assuming that it has already
+// been tried to resolve, checks if it contains any acceptable response, and if
+// it doesn't, performs DNS64 request and the following synthesis.  It returns
+// the [resultCodeError] if there was an error set to dctx.
+func (s *Server) performDNS64(prx *proxy.Proxy, dctx *dnsContext) (rc resultCode) {
+	pctx := dctx.proxyCtx
+	req := pctx.Req
+
+	dns64Req := s.checkDNS64(req, pctx.Res)
+	if dns64Req == nil {
+		return resultCodeSuccess
+	}
+
+	log.Debug("dnsforward: received an empty AAAA response, checking DNS64")
+
+	origReq := pctx.Req
+	origResp := pctx.Res
+	origUps := pctx.Upstream
+
+	pctx.Req = dns64Req
+	defer func() { pctx.Req = origReq }()
+
+	if dctx.err = prx.Resolve(pctx); dctx.err != nil {
+		return resultCodeError
+	}
+
+	dns64Resp := pctx.Res
+	pctx.Res = origResp
+	if dns64Resp != nil && s.synthDNS64(origReq, pctx.Res, dns64Resp) {
+		log.Debug("dnsforward: synthesized AAAA response for %q", origReq.Question[0].Name)
+	} else {
+		pctx.Upstream = origUps
+	}
+
+	return resultCodeSuccess
+}
+
+// synthRR synthesizes a DNS64 resource record in compliance with RFC 6147.  If
+// rr is not an A record, it's returned as is.  A records are modified to become
+// a DNS64-synthesized AAAA records, and the TTL is set according to the
+// original TTL of a record and soaTTL.  It returns nil on invalid A records.
+func (s *Server) synthRR(rr dns.RR, soaTTL uint32) (result dns.RR) {
+	aResp, ok := rr.(*dns.A)
+	if !ok {
+		return rr
+	}
+
+	addr, err := netutil.IPToAddr(aResp.A, netutil.AddrFamilyIPv4)
+	if err != nil {
+		log.Error("dnsforward: bad A record: %s", err)
+
+		return nil
+	}
+
+	aaaa := &dns.AAAA{
+		Hdr: dns.RR_Header{
+			Name:   aResp.Hdr.Name,
+			Rrtype: dns.TypeAAAA,
+			Class:  aResp.Hdr.Class,
+		},
+		AAAA: s.mapDNS64(addr),
+	}
+
+	if rrTTL := aResp.Hdr.Ttl; rrTTL < soaTTL {
+		aaaa.Hdr.Ttl = rrTTL
+	} else {
+		aaaa.Hdr.Ttl = soaTTL
+	}
+
+	return aaaa
+}
diff --git a/internal/dnsforward/dns64_test.go b/internal/dnsforward/dns64_test.go
new file mode 100644
index 00000000..12925504
--- /dev/null
+++ b/internal/dnsforward/dns64_test.go
@@ -0,0 +1,290 @@
+package dnsforward
+
+import (
+	"net"
+	"testing"
+	"time"
+
+	"github.com/AdguardTeam/AdGuardHome/internal/aghtest"
+	"github.com/AdguardTeam/AdGuardHome/internal/filtering"
+	"github.com/AdguardTeam/dnsproxy/proxy"
+	"github.com/AdguardTeam/dnsproxy/upstream"
+	"github.com/AdguardTeam/golibs/netutil"
+	"github.com/AdguardTeam/golibs/testutil"
+	"github.com/miekg/dns"
+	"github.com/stretchr/testify/require"
+)
+
+// newRR is a helper that creates a new dns.RR with the given name, qtype, ttl
+// and value.  It fails the test if the qtype is not supported or the type of
+// value doesn't match the qtype.
+func newRR(t *testing.T, name string, qtype uint16, ttl uint32, val any) (rr dns.RR) {
+	t.Helper()
+
+	switch qtype {
+	case dns.TypeA:
+		rr = &dns.A{A: testutil.RequireTypeAssert[net.IP](t, val)}
+	case dns.TypeAAAA:
+		rr = &dns.AAAA{AAAA: testutil.RequireTypeAssert[net.IP](t, val)}
+	case dns.TypeCNAME:
+		rr = &dns.CNAME{Target: testutil.RequireTypeAssert[string](t, val)}
+	case dns.TypeSOA:
+		rr = &dns.SOA{
+			Ns:      "ns." + name,
+			Mbox:    "hostmaster." + name,
+			Serial:  1,
+			Refresh: 1,
+			Retry:   1,
+			Expire:  1,
+			Minttl:  1,
+		}
+	case dns.TypePTR:
+		rr = &dns.PTR{Ptr: testutil.RequireTypeAssert[string](t, val)}
+	default:
+		t.Fatalf("unsupported qtype: %d", qtype)
+	}
+
+	*rr.Header() = dns.RR_Header{
+		Name:   name,
+		Rrtype: qtype,
+		Class:  dns.ClassINET,
+		Ttl:    ttl,
+	}
+
+	return rr
+}
+
+func TestServer_HandleDNSRequest_dns64(t *testing.T) {
+	const (
+		ipv4Domain    = "ipv4.only."
+		ipv6Domain    = "ipv6.only."
+		soaDomain     = "ipv4.soa."
+		mappedDomain  = "filterable.ipv6."
+		anotherDomain = "another.domain."
+
+		pointedDomain = "local1234.ipv4."
+		globDomain    = "real1234.ipv4."
+	)
+
+	someIPv4 := net.IP{1, 2, 3, 4}
+	someIPv6 := net.IP{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
+	mappedIPv6 := net.ParseIP("64:ff9b::102:304")
+
+	ptr64Domain, err := netutil.IPToReversedAddr(mappedIPv6)
+	require.NoError(t, err)
+	ptr64Domain = dns.Fqdn(ptr64Domain)
+
+	ptrGlobDomain, err := netutil.IPToReversedAddr(someIPv4)
+	require.NoError(t, err)
+	ptrGlobDomain = dns.Fqdn(ptrGlobDomain)
+
+	const (
+		sectionAnswer = iota
+		sectionAuthority
+		sectionAdditional
+
+		sectionsNum
+	)
+
+	// answerMap is a convenience alias for describing the upstream response for
+	// a given question type.
+	type answerMap = map[uint16][sectionsNum][]dns.RR
+
+	pt := testutil.PanicT{}
+	newUps := func(answers answerMap) (u upstream.Upstream) {
+		return aghtest.NewUpstreamMock(func(req *dns.Msg) (resp *dns.Msg, err error) {
+			q := req.Question[0]
+			require.Contains(pt, answers, q.Qtype)
+
+			answer := answers[q.Qtype]
+
+			resp = (&dns.Msg{}).SetReply(req)
+			resp.Answer = answer[sectionAnswer]
+			resp.Ns = answer[sectionAuthority]
+			resp.Extra = answer[sectionAdditional]
+
+			return resp, nil
+		})
+	}
+
+	testCases := []struct {
+		name    string
+		qname   string
+		upsAns  answerMap
+		wantAns []dns.RR
+		qtype   uint16
+	}{{
+		name:  "simple_a",
+		qname: ipv4Domain,
+		upsAns: answerMap{
+			dns.TypeA: {
+				sectionAnswer: {newRR(t, ipv4Domain, dns.TypeA, 3600, someIPv4)},
+			},
+			dns.TypeAAAA: {},
+		},
+		wantAns: []dns.RR{&dns.A{
+			Hdr: dns.RR_Header{
+				Name:     ipv4Domain,
+				Rrtype:   dns.TypeA,
+				Class:    dns.ClassINET,
+				Ttl:      3600,
+				Rdlength: 4,
+			},
+			A: someIPv4,
+		}},
+		qtype: dns.TypeA,
+	}, {
+		name:  "simple_aaaa",
+		qname: ipv6Domain,
+		upsAns: answerMap{
+			dns.TypeA: {},
+			dns.TypeAAAA: {
+				sectionAnswer: {newRR(t, ipv6Domain, dns.TypeAAAA, 3600, someIPv6)},
+			},
+		},
+		wantAns: []dns.RR{&dns.AAAA{
+			Hdr: dns.RR_Header{
+				Name:     ipv6Domain,
+				Rrtype:   dns.TypeAAAA,
+				Class:    dns.ClassINET,
+				Ttl:      3600,
+				Rdlength: 16,
+			},
+			AAAA: someIPv6,
+		}},
+		qtype: dns.TypeAAAA,
+	}, {
+		name:  "actual_dns64",
+		qname: ipv4Domain,
+		upsAns: answerMap{
+			dns.TypeA: {
+				sectionAnswer: {newRR(t, ipv4Domain, dns.TypeA, 3600, someIPv4)},
+			},
+			dns.TypeAAAA: {},
+		},
+		wantAns: []dns.RR{&dns.AAAA{
+			Hdr: dns.RR_Header{
+				Name:     ipv4Domain,
+				Rrtype:   dns.TypeAAAA,
+				Class:    dns.ClassINET,
+				Ttl:      maxDNS64SynTTL,
+				Rdlength: 16,
+			},
+			AAAA: mappedIPv6,
+		}},
+		qtype: dns.TypeAAAA,
+	}, {
+		name:  "actual_dns64_soattl",
+		qname: soaDomain,
+		upsAns: answerMap{
+			dns.TypeA: {
+				sectionAnswer: {newRR(t, soaDomain, dns.TypeA, 3600, someIPv4)},
+			},
+			dns.TypeAAAA: {
+				sectionAuthority: {newRR(t, soaDomain, dns.TypeSOA, maxDNS64SynTTL+50, nil)},
+			},
+		},
+		wantAns: []dns.RR{&dns.AAAA{
+			Hdr: dns.RR_Header{
+				Name:     soaDomain,
+				Rrtype:   dns.TypeAAAA,
+				Class:    dns.ClassINET,
+				Ttl:      maxDNS64SynTTL + 50,
+				Rdlength: 16,
+			},
+			AAAA: mappedIPv6,
+		}},
+		qtype: dns.TypeAAAA,
+	}, {
+		name:  "filtered",
+		qname: mappedDomain,
+		upsAns: answerMap{
+			dns.TypeA: {},
+			dns.TypeAAAA: {
+				sectionAnswer: {
+					newRR(t, mappedDomain, dns.TypeAAAA, 3600, net.ParseIP("64:ff9b::506:708")),
+					newRR(t, mappedDomain, dns.TypeCNAME, 3600, anotherDomain),
+				},
+			},
+		},
+		wantAns: []dns.RR{&dns.CNAME{
+			Hdr: dns.RR_Header{
+				Name:     mappedDomain,
+				Rrtype:   dns.TypeCNAME,
+				Class:    dns.ClassINET,
+				Ttl:      3600,
+				Rdlength: 16,
+			},
+			Target: anotherDomain,
+		}},
+		qtype: dns.TypeAAAA,
+	}, {
+		name:   "ptr",
+		qname:  ptr64Domain,
+		upsAns: nil,
+		wantAns: []dns.RR{&dns.PTR{
+			Hdr: dns.RR_Header{
+				Name:     ptr64Domain,
+				Rrtype:   dns.TypePTR,
+				Class:    dns.ClassINET,
+				Ttl:      3600,
+				Rdlength: 16,
+			},
+			Ptr: pointedDomain,
+		}},
+		qtype: dns.TypePTR,
+	}, {
+		name:  "ptr_glob",
+		qname: ptrGlobDomain,
+		upsAns: answerMap{
+			dns.TypePTR: {
+				sectionAnswer: {newRR(t, ptrGlobDomain, dns.TypePTR, 3600, globDomain)},
+			},
+		},
+		wantAns: []dns.RR{&dns.PTR{
+			Hdr: dns.RR_Header{
+				Name:     ptrGlobDomain,
+				Rrtype:   dns.TypePTR,
+				Class:    dns.ClassINET,
+				Ttl:      3600,
+				Rdlength: 15,
+			},
+			Ptr: globDomain,
+		}},
+		qtype: dns.TypePTR,
+	}}
+
+	localRR := newRR(t, ptr64Domain, dns.TypePTR, 3600, pointedDomain)
+	localUps := aghtest.NewUpstreamMock(func(req *dns.Msg) (resp *dns.Msg, err error) {
+		require.Equal(pt, req.Question[0].Name, ptr64Domain)
+		resp = (&dns.Msg{}).SetReply(req)
+		resp.Answer = []dns.RR{localRR}
+
+		return resp, nil
+	})
+
+	s := createTestServer(t, &filtering.Config{}, ServerConfig{
+		UDPListenAddrs: []*net.UDPAddr{{}},
+		TCPListenAddrs: []*net.TCPAddr{{}},
+		UseDNS64:       true,
+	}, localUps)
+
+	client := &dns.Client{
+		Net:     "tcp",
+		Timeout: 1 * time.Second,
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			s.conf.UpstreamConfig.Upstreams = []upstream.Upstream{newUps(tc.upsAns)}
+			startDeferStop(t, s)
+
+			req := (&dns.Msg{}).SetQuestion(tc.qname, tc.qtype)
+
+			resp, _, excErr := client.Exchange(req, s.dnsProxy.Addr(proxy.ProtoTCP).String())
+			require.NoError(t, excErr)
+
+			require.Equal(t, tc.wantAns, resp.Answer)
+		})
+	}
+}
diff --git a/internal/dnsforward/dnsforward.go b/internal/dnsforward/dnsforward.go
index 8999845b..4ff9fc02 100644
--- a/internal/dnsforward/dnsforward.go
+++ b/internal/dnsforward/dnsforward.go
@@ -82,6 +82,9 @@ type Server struct {
 	sysResolvers   aghnet.SystemResolvers
 	recDetector    *recursionDetector
 
+	// dns64Prefix is the set of NAT64 prefixes used for DNS64 handling.
+	dns64Prefs []netip.Prefix
+
 	// anonymizer masks the client's IP addresses if needed.
 	anonymizer *aghnet.IPMut
 
@@ -488,9 +491,11 @@ func (s *Server) Prepare(conf *ServerConfig) (err error) {
 		return fmt.Errorf("preparing access: %w", err)
 	}
 
-	if !webRegistered && s.conf.HTTPRegister != nil {
-		webRegistered = true
-		s.registerHandlers()
+	s.registerHandlers()
+
+	err = s.setupDNS64()
+	if err != nil {
+		return fmt.Errorf("preparing DNS64: %w", err)
 	}
 
 	s.dnsProxy = &proxy.Proxy{Config: proxyConfig}
diff --git a/internal/dnsforward/http.go b/internal/dnsforward/http.go
index 5668573b..18c4e82e 100644
--- a/internal/dnsforward/http.go
+++ b/internal/dnsforward/http.go
@@ -712,6 +712,10 @@ func (s *Server) handleDoH(w http.ResponseWriter, r *http.Request) {
 }
 
 func (s *Server) registerHandlers() {
+	if webRegistered || s.conf.HTTPRegister == nil {
+		return
+	}
+
 	s.conf.HTTPRegister(http.MethodGet, "/control/dns_info", s.handleGetConfig)
 	s.conf.HTTPRegister(http.MethodPost, "/control/dns_config", s.handleSetConfig)
 	s.conf.HTTPRegister(http.MethodPost, "/control/test_upstream_dns", s.handleTestUpstreamDNS)
@@ -730,4 +734,6 @@ func (s *Server) registerHandlers() {
 	// See also https://github.com/AdguardTeam/AdGuardHome/issues/2628.
 	s.conf.HTTPRegister("", "/dns-query", s.handleDoH)
 	s.conf.HTTPRegister("", "/dns-query/", s.handleDoH)
+
+	webRegistered = true
 }
diff --git a/internal/home/config.go b/internal/home/config.go
index d5b8fdf3..f2fc98f5 100644
--- a/internal/home/config.go
+++ b/internal/home/config.go
@@ -184,6 +184,12 @@ type dnsConfig struct {
 	// for PTR queries for locally-served networks.
 	LocalPTRResolvers []string `yaml:"local_ptr_upstreams"`
 
+	// UseDNS64 defines if DNS64 should be used for incoming requests.
+	UseDNS64 bool `yaml:"use_dns64"`
+
+	// DNS64Prefixes is the list of NAT64 prefixes to be used for DNS64.
+	DNS64Prefixes []string `yaml:"dns64_prefixes"`
+
 	// ServeHTTP3 defines if HTTP/3 is be allowed for incoming requests.
 	//
 	// TODO(a.garipov): Add to the UI when HTTP/3 support is no longer
diff --git a/internal/home/dns.go b/internal/home/dns.go
index 9d073d7b..4db9b1b2 100644
--- a/internal/home/dns.go
+++ b/internal/home/dns.go
@@ -242,6 +242,8 @@ func generateServerConfig(
 		ConfigModified:  onConfigModified,
 		HTTPRegister:    httpReg,
 		OnDNSRequest:    onDNSRequest,
+		UseDNS64:        config.DNS.UseDNS64,
+		DNS64Prefixes:   config.DNS.DNS64Prefixes,
 	}
 
 	if tlsConf.Enabled {