diff --git a/lib/layer/validate.c b/lib/layer/validate.c
index 87c61c402f4864c720e59d4b3e56069b2cc6803e..11ac4d220d7c61eac6fd0acb725030d063802794 100644
--- a/lib/layer/validate.c
+++ b/lib/layer/validate.c
@@ -19,6 +19,7 @@
 
 #include <libknot/descriptor.h>
 #include <libknot/rrtype/rdname.h>
+#include <libknot/rrtype/dnskey.h>
 
 #include "lib/layer/iterate.h"
 #include "lib/resolve.h"
@@ -27,7 +28,7 @@
 #include "lib/nsrep.h"
 #include "lib/module.h"
 
-#define DEBUG_MSG(fmt...) QRDEBUG(kr_rplan_current(&req->rplan), "vldr", fmt)
+#define DEBUG_MSG(fmt...) QRDEBUG(qry, "vldr", fmt)
 
 /* Set resolution context and parameters. */
 static int begin(knot_layer_t *ctx, void *module_param)
@@ -36,6 +37,7 @@ static int begin(knot_layer_t *ctx, void *module_param)
 	return KNOT_STATE_PRODUCE;
 }
 
+#if 0
 static int secure_query(knot_layer_t *ctx, knot_pkt_t *pkt)
 {
 	assert(pkt && ctx);
@@ -91,20 +93,105 @@ static int secure_query(knot_layer_t *ctx, knot_pkt_t *pkt)
 #endif
 	return ctx->state;
 }
+#endif
+
+static int validate_records(struct kr_query *qry, knot_pkt_t *answer)
+{
+#warning TODO: validate RRSIGS (records with ZSK, keys with KSK), return FAIL if failed
+	if (!qry->zone_cut.key) {
+		DEBUG_MSG("<= no DNSKEY, can't validate\n");
+	}
+
+	DEBUG_MSG("!! validation not implemented\n");
+	return kr_error(ENOSYS);
+}
+
+static int validate_proof(struct kr_query *qry, knot_pkt_t *answer)
+{
+#warning TODO: validate NSECx proof, RRSIGs will be checked later if it matches
+	return kr_ok();
+}
+
+static int validate_keyset(struct kr_query *qry, knot_pkt_t *answer)
+{
+	/* Merge DNSKEY records from answer */
+	const knot_pktsection_t *an = knot_pkt_section(answer, KNOT_ANSWER);
+	for (unsigned i = 0; i < an->count; ++i) {
+		const knot_rrset_t *rr = knot_pkt_rr(an, i);
+		if (rr->type == KNOT_RRTYPE_DNSKEY) {
+			DEBUG_MSG("+= DNSKEY flags: %hu algo: %x\n",
+				knot_dnskey_flags(&rr->rrs, 0),
+				0xff & knot_dnskey_alg(&rr->rrs, 0));
+#warning TODO: merge with zone cut 'key' RRSet
+		}
+	}
+	/* Check if there's a key for current TA. */
+#warning TODO: check if there is a DNSKEY we can trust (matching current TA)
+	return kr_ok();
+}
+
+static int update_delegation(struct kr_query *qry, knot_pkt_t *answer)
+{
+	DEBUG_MSG("<= referral, checking DS\n");
+#warning TODO: delegation, check DS record presence
+	return kr_ok();
+}
 
 static int validate(knot_layer_t *ctx, knot_pkt_t *pkt)
 {
+	int ret = 0;
 	struct kr_request *req = ctx->data;
-	struct kr_query *query = kr_rplan_current(&req->rplan);
+	struct kr_query *qry = kr_rplan_current(&req->rplan);
 	if (ctx->state & KNOT_STATE_FAIL) {
 		return ctx->state;
 	}
-#warning TODO: check if we have DNSKEY in qry->zone_cut and validate RRSIGS/proof, return FAIL if failed
-#warning TODO: we must also validate incoming DNSKEY records against the current zone cut TA
-#warning FLOW: first answer that comes here must have the DNSKEY that we can validate using TA
-	DEBUG_MSG("checking query, dnskey: %d, secured: %d\n",
-		  knot_pkt_qtype(pkt) == KNOT_RRTYPE_DNSKEY,
-		  knot_pkt_has_dnssec(pkt));
+
+	/* Pass-through if user doesn't want secure answer. */
+	if (!(req->flags & KR_REQ_DNSSEC)) {
+		return ctx->state;
+	}
+
+	/* Server didn't copy back DO=1, this is okay if it doesn't have DS => insecure.
+	 * If it has DS, it must be secured, fail it as bogus. */
+	if (!knot_pkt_has_dnssec(pkt)) {
+		DEBUG_MSG("<= asked with DO=1, got insecure response\n");
+#warning TODO: fail and retry if it has TA, otherwise flag as INSECURE and continue
+		return KNOT_STATE_FAIL;
+	}
+
+	/* Validate non-existence proof if not positive answer. */	
+	if (knot_wire_get_rcode(pkt->wire) == KNOT_RCODE_NXDOMAIN) {
+		ret = validate_proof(qry, pkt);
+		if (ret != 0) {
+			DEBUG_MSG("<= bad NXDOMAIN proof\n");
+			qry->flags |= QUERY_DNSSEC_BOGUS;
+			return KNOT_STATE_FAIL;
+		}
+	}
+
+	/* Check if this is a DNSKEY answer, check trust chain and store. */
+	uint16_t qtype = knot_pkt_qtype(pkt);
+	if (qtype == KNOT_RRTYPE_DNSKEY) {
+		ret = validate_keyset(qry, pkt);
+		if (ret != 0) {
+			DEBUG_MSG("<= bad keys, broken trust chain\n");
+			qry->flags |= QUERY_DNSSEC_BOGUS;
+			return KNOT_STATE_FAIL;
+		}
+	/* Update trust anchor. */
+	} else if (qtype == KNOT_RRTYPE_NS) {
+		update_delegation(qry, pkt);
+	}
+
+	/* Validate all records, fail as bogus if it doesn't match. */
+	ret = validate_records(qry, pkt);
+	if (ret != 0) {
+		DEBUG_MSG("<= couldn't validate RRSIGs\n");
+		qry->flags |= QUERY_DNSSEC_BOGUS;
+		return KNOT_STATE_FAIL;
+	}
+
+	DEBUG_MSG("<= answer valid, OK\n");
 	return ctx->state;
 }
 
diff --git a/lib/resolve.c b/lib/resolve.c
index dfcb78da65c1d8da702cace3a3f69410af9a431b..c4c718aa82c23c0410cca0b4314b11a926bcb1f7 100644
--- a/lib/resolve.c
+++ b/lib/resolve.c
@@ -397,6 +397,7 @@ int kr_resolve_consume(struct kr_request *request, knot_pkt_t *packet)
 	if (state == KNOT_STATE_FAIL) {
 		kr_nsrep_update_rtt(&qry->ns, KR_NS_TIMEOUT, ctx->cache_rtt);
 		invalidate_ns(rplan, qry);
+		qry->flags &= ~QUERY_RESOLVED;
 	/* Track RTT for iterative answers */
 	} else if (!(qry->flags & QUERY_CACHED)) {
 		struct timeval now;
@@ -414,6 +415,11 @@ int kr_resolve_consume(struct kr_request *request, knot_pkt_t *packet)
 	}
 
 	knot_overlay_reset(&request->overlay);
+
+	/* Do not finish with bogus answer. */
+	if (qry->flags & QUERY_DNSSEC_BOGUS)  {
+		return KNOT_STATE_FAIL;
+	}
 	return kr_rplan_empty(&request->rplan) ? KNOT_STATE_DONE : KNOT_STATE_PRODUCE;
 }
 
@@ -466,13 +472,11 @@ int kr_resolve_produce(struct kr_request *request, struct sockaddr **dst, int *t
 		}
 		/* Try to fetch missing DNSKEY. */
 		if (want_secured && !qry->zone_cut.key && qry->stype != KNOT_RRTYPE_DNSKEY) {
-			/* TODO -- Fetch all missing DNSKEYS and DS records. */
-			/* TODO -- Fetch DS at parent side of a zone cut. Fetch NS at the child side of the zone cut. */
-			/* TODO -- Handle holes (sequences with missing delegation). */
 			struct kr_query *next = kr_rplan_push(rplan, qry, qry->zone_cut.name, KNOT_CLASS_IN, KNOT_RRTYPE_DNSKEY);
 			if (!next) {
 				return kr_error(ENOMEM);
 			}
+			next->flags |= QUERY_AWAIT_CUT;
 			return KNOT_STATE_PRODUCE;
 		}
 		/* Update minimized QNAME if zone cut changed */
@@ -539,7 +543,7 @@ int kr_resolve_finish(struct kr_request *request, int state)
 	DEBUG_MSG("finished: %d, mempool: %zu B\n", state, (size_t) mp_total_size(request->pool.ctx));
 #endif
 	/* Finalize answer */
-	if (answer_finalize(request->answer) != 0) {
+	if (answer_finalize(request, state) != 0) {
 		state = KNOT_STATE_FAIL;
 	}
 	/* Error during procesing, internal failure */
diff --git a/lib/rplan.h b/lib/rplan.h
index ccf300a1baf625eb03fb533a100ea00f09b325ef..71ce52b3ee894ad1e2c62fe18a2d1c5c2f4dc903 100644
--- a/lib/rplan.h
+++ b/lib/rplan.h
@@ -27,16 +27,17 @@
 #include "lib/nsrep.h"
 
 #define QUERY_FLAGS(X) \
-	X(NO_MINIMIZE, 1 << 0) /**< Don't minimize QNAME. */ \
-	X(NO_THROTTLE, 1 << 1) /**< No query/slow NS throttling. */ \
-	X(TCP        , 1 << 2) /**< Use TCP for this query. */ \
-	X(RESOLVED   , 1 << 3) /**< Query is resolved. */ \
-	X(AWAIT_IPV4 , 1 << 4) /**< Query is waiting for A address. */ \
-	X(AWAIT_IPV6 , 1 << 5) /**< Query is waiting for AAAA address. */ \
-	X(AWAIT_CUT  , 1 << 6) /**< Query is waiting for zone cut lookup */ \
-	X(SAFEMODE   , 1 << 7) /**< Don't use fancy stuff (EDNS...) */ \
-	X(CACHED     , 1 << 8) /**< Query response is cached. */ \
-	X(EXPIRING   , 1 << 9) /**< Query response is cached, but expiring. */
+	X(NO_MINIMIZE  , 1 << 0) /**< Don't minimize QNAME. */ \
+	X(NO_THROTTLE  , 1 << 1) /**< No query/slow NS throttling. */ \
+	X(TCP          , 1 << 2) /**< Use TCP for this query. */ \
+	X(RESOLVED     , 1 << 3) /**< Query is resolved. */ \
+	X(AWAIT_IPV4   , 1 << 4) /**< Query is waiting for A address. */ \
+	X(AWAIT_IPV6   , 1 << 5) /**< Query is waiting for AAAA address. */ \
+	X(AWAIT_CUT    , 1 << 6) /**< Query is waiting for zone cut lookup */ \
+	X(SAFEMODE     , 1 << 7) /**< Don't use fancy stuff (EDNS...) */ \
+	X(CACHED       , 1 << 8) /**< Query response is cached. */ \
+	X(EXPIRING     , 1 << 9) /**< Query response is cached, but expiring. */ \
+	X(DNSSEC_BOGUS , 1 << 10) /**< Query response is DNSSEC bogus. */ \
 
 /** Query flags */
 enum kr_query_flag {
diff --git a/lib/zonecut.c b/lib/zonecut.c
index 3e9754a7e7bd56a929ceff21efd421b347d736f7..3532fc844ef1e55ae7db01d55f8d39f0788eb785 100644
--- a/lib/zonecut.c
+++ b/lib/zonecut.c
@@ -265,6 +265,7 @@ int kr_zonecut_set_sbelt(struct kr_context *ctx, struct kr_zonecut *cut)
 		}
 	}
 
+#warning TODO: set root trust anchor from config, or hardcode for now
 	return kr_ok();
 }
 
@@ -358,6 +359,7 @@ int kr_zonecut_find_cached(struct kr_context *ctx, struct kr_zonecut *cut, const
 
 	/* Start at QNAME parent. */
 	while (txn) {
+#warning TODO: find closest trust anchor here, then find NS
 		bool has_key = !secured || fetch_dnskey(ctx, cut, name, txn, timestamp) == 0;
 		if (has_key && fetch_ns(ctx, cut, name, txn, timestamp) == 0) {
 			update_cut_name(cut, name);
diff --git a/lib/zonecut.h b/lib/zonecut.h
index 1cd101655bc0d99f65a89afb213d0fa658267b5e..e9376aa6824fc381c8273a088a3b3229b731a36c 100644
--- a/lib/zonecut.h
+++ b/lib/zonecut.h
@@ -31,6 +31,7 @@ struct kr_zonecut {
 	mm_ctx_t *pool;     /**< Memory pool. */
 	map_t nsset;        /**< Map of nameserver => address_set. */
 	knot_rrset_t* key;  /**< Zone cut DNSKEY. */
+	knot_rrset_t* trust_anchor; /**< Current trust anchor. */
 };
 
 /**
diff --git a/tests/test_integration.c b/tests/test_integration.c
index 4b8ee463b9a207649a82ade0db1141dd5234ebac..b853a18395bfddb14cc36a1323fbc00adccb50b5 100644
--- a/tests/test_integration.c
+++ b/tests/test_integration.c
@@ -52,8 +52,8 @@ static PyObject* init(PyObject* self, PyObject* args)
 
 	/* Load basic modules. */
 	array_init(global_modules);
-	const char *load_modules[3] = {"iterate", "rrcache", "pktcache"};
-	for (unsigned i = 0; i < 3; ++i) {
+	const char *load_modules[4] = {"iterate", "validate", "rrcache", "pktcache" };
+	for (unsigned i = 0; i < 4; ++i) {
 		struct kr_module *mod = malloc(sizeof(*mod));
 		kr_module_load(mod, load_modules[i], NULL);
 		array_push(global_modules, mod);
diff --git a/tests/testdata/iter_validate.rpl b/tests/testdata/iter_validate.rpl
new file mode 100644
index 0000000000000000000000000000000000000000..cc1fbeb8654472d207faf980ecfb9fb1b9093f91
--- /dev/null
+++ b/tests/testdata/iter_validate.rpl
@@ -0,0 +1,117 @@
+; config options
+server:
+	trust-anchor: ". 3600 IN DS 19036 8 2 49AAC11D7B6F6446702E54A1607371607A1A41855200FD2CE1CDDE32F24E8FB5"
+	val-override-date: "1436477112"
+
+stub-zone:
+	name: "."
+	stub-addr: 198.41.0.4 	# a.root-servers.net.
+CONFIG_END
+
+SCENARIO_BEGIN Test basic validation of NS cz. (two levels)
+
+; K.ROOT-SERVERS.NET.
+RANGE_BEGIN 0 100
+	ADDRESS 198.41.0.4
+ENTRY_BEGIN
+MATCH opcode qtype qname
+ADJUST copy_id
+REPLY QR AA NOERROR
+SECTION QUESTION
+. IN NS
+SECTION ANSWER
+.			518400	IN	NS	a.root-servers.net.
+.			518400	IN	NS	b.root-servers.net.
+.			518400	IN	NS	c.root-servers.net.
+.			518400	IN	NS	d.root-servers.net.
+.			518400	IN	NS	e.root-servers.net.
+.			518400	IN	NS	f.root-servers.net.
+.			518400	IN	NS	g.root-servers.net.
+.			518400	IN	NS	h.root-servers.net.
+.			518400	IN	NS	i.root-servers.net.
+.			518400	IN	NS	j.root-servers.net.
+.			518400	IN	NS	k.root-servers.net.
+.			518400	IN	NS	l.root-servers.net.
+.			518400	IN	NS	m.root-servers.net.
+.			518400	IN	RRSIG	NS 8 0 518400 20150719170000 20150709160000 1518 . qoRtgQy1XMDlvBufwjClMyMJRXlcHEl7+Z9mn8BRqZJiAmpYbk+Ku1Z2 omfnUsHX9fLhyLuIRS/FKN4/WPVmcVOxZ09EgZ9hFBH/pn2LOlhQ6abP OIrpy9slr/i9DGZ2YXirKIUWUKUfpcLv0O+g7DZZcqnmhGCikNtXgJ76 njw=
+SECTION ADDITIONAL
+a.root-servers.net.	518400	IN	A	198.41.0.4
+ENTRY_END
+
+
+ENTRY_BEGIN
+MATCH opcode qtype qname
+ADJUST copy_id
+REPLY QR AA NOERROR
+SECTION QUESTION
+. IN DNSKEY
+SECTION ANSWER
+.			172800	IN	DNSKEY	256 3 8 AwEAAZyIkCwEYeG29NV+4cOdKE4DPng/4BqJeoOhKqzJbl+LR33TPWsr wBRfmAi9wvR/Qc6IV4MFMXjmkclXns+atIQZ9uQV3YAvKv/cVuO7Mneu MssIQixaMw+jp73R7zIUNMbLBgJRQXI57Rl+pvXBAkgHndVwv+aJkf7y GEuE9Dtj
+.			172800	IN	DNSKEY	256 3 8 AwEAAa67bQck1JjopOOFc+iMISFcp/osWrEst2wbKbuQSUWu77QC9UHL ipiHgWN7JlqVAEjKITZz49hhkLmOpmLK55pTq+RD2kwoyNWk9cvpc+tS nIxT7i93O+3oVeLYjMWrkDAz7K45rObbHDuSBwYZKrcSIUCZnCpNMUtn PFl/04cb
+.			172800	IN	DNSKEY	257 3 8 AwEAAagAIKlVZrpC6Ia7gEzahOR+9W29euxhJhVVLOyQbSEW0O8gcCjF FVQUTf6v58fLjwBd0YI0EzrAcQqBGCzh/RStIoO8g0NfnfL2MTJRkxoX bfDaUeVPQuYEhg37NZWAJQ9VnMVDxP/VHL496M/QZxkjf5/Efucp2gaD X6RS6CXpoY68LsvPVjR0ZSwzz1apAzvN9dlzEheX7ICJBBtuA6G3LQpz W5hOA2hzCTMjJPJ8LbqF6dsV6DoBQzgul0sGIcGOYl7OyQdXfZ57relS Qageu+ipAdTTJ25AsRTAoub8ONGcLmqrAmRLKBP1dfwhYB4N7knNnulq QxA+Uk1ihz0=
+.			172800	IN	RRSIG	DNSKEY 8 0 172800 20150715235959 20150701000000 19036 . PCLEqe8X433LWIWcrP5jC3Ejjws7XST8CFpiccRKXuB9YGMi3AngOXf4 4wiXG1WXLNb+5Rj/na6/4znyTd3sb21T9syHol9kaVMSXzIGg07yZ0hk 62BVdwKOPphtivSwYYjZYxXXXMR/X1MZNwD6a63Tz2Q+zMnS897CTo3M gMsU8qUUwPAqS52rP82Bf0L3Uhr7KWwfHrfH9CN4kAxi8ha2EWSaHNCo jTHUXnaGDDOtUQUSvrgI8vlBRjncr45ktmUh7a8OF2AxoPlfd/FTp6Dy 1f3B90G8fGml0LU/dQVOr3PWMRmmELhY/QQpL+FptAnxIVPeg7jJsZ9A P3OrmA==
+ENTRY_END
+
+ENTRY_BEGIN
+MATCH opcode qtype qname
+ADJUST copy_id
+REPLY QR NOERROR
+SECTION QUESTION
+cz. IN NS
+SECTION AUTHORITY
+cz.			172800	IN	NS	a.ns.nic.cz.
+cz.			86400	IN	DS	54576 10 2 397E50C85EDE9CDE33F363A9E66FD1B216D788F8DD438A57A423A386 869C8F06
+cz.			86400	IN	RRSIG	DS 8 1 86400 20150719170000 20150709160000 1518 . nzjFR1npBJ8enQ4jXm9DZ8S8FheWzMps9xourERlhaal8buDVwyGnWWW wy9z3d+a3hXtYCL+rOJLm9tz+GVRQuZRk6Yp//5ckTGn0HwymIIXw3nU LEfGafsgBIQZiW7eyMq/zwZMjOSQ9KRMomjh1clUxQsuxUrkw/mJMMZI 0R0=
+SECTION ADDITIONAL
+a.ns.nic.cz.		172800	IN	A	194.0.12.1
+ENTRY_END
+RANGE_END
+
+; a.ns.nic.cz.
+RANGE_BEGIN 0 100
+	ADDRESS 194.0.12.1
+ENTRY_BEGIN
+MATCH opcode qtype qname
+ADJUST copy_id
+REPLY QR AA NOERROR
+SECTION QUESTION
+cz. IN NS
+SECTION ANSWER
+cz.			18000	IN	NS	a.ns.nic.cz.
+cz.			18000	IN	NS	b.ns.nic.cz.
+cz.			18000	IN	NS	c.ns.nic.cz.
+cz.			18000	IN	NS	d.ns.nic.cz.
+cz.			18000	IN	RRSIG	NS 10 1 18000 20150722014826 20150708113847 39788 cz. fXbYxeKOypz1mouiC3PTYSUv16rGy93f1GNRwIvNkJQHrDAXImLX6JoS aESL9cm671WJ4d8MgjrCcdaeILyFPnsb2zoSn96sreABtk3zZz54xk23 dzQmXIMSwXDHIpfdF2Adsh+JOblOYuLrgepyG59IRKdPS0UVyDUlIfia slQ=
+SECTION ADDITIONAL
+a.ns.nic.cz.		18000	IN	A	194.0.12.1
+b.ns.nic.cz.		18000	IN	A	194.0.12.1
+c.ns.nic.cz.		18000	IN	A	194.0.12.1
+d.ns.nic.cz.		18000	IN	A	194.0.12.1
+ENTRY_END
+
+RANGE_END
+
+
+STEP 1 QUERY
+ENTRY_BEGIN
+REPLY RD DO
+SECTION QUESTION
+cz. IN NS
+ENTRY_END
+
+; recursion happens here.
+STEP 10 CHECK_ANSWER
+ENTRY_BEGIN
+MATCH all
+REPLY QR RD RA AD NOERROR
+SECTION QUESTION
+cz. IN NS
+SECTION ANSWER
+cz.			18000	IN	NS	a.ns.nic.cz.
+cz.			18000	IN	NS	b.ns.nic.cz.
+cz.			18000	IN	NS	c.ns.nic.cz.
+cz.			18000	IN	NS	d.ns.nic.cz.
+cz.			18000	IN	RRSIG	NS 10 1 18000 20150722014826 20150708113847 39788 cz. fXbYxeKOypz1mouiC3PTYSUv16rGy93f1GNRwIvNkJQHrDAXImLX6JoS aESL9cm671WJ4d8MgjrCcdaeILyFPnsb2zoSn96sreABtk3zZz54xk23 dzQmXIMSwXDHIpfdF2Adsh+JOblOYuLrgepyG59IRKdPS0UVyDUlIfia slQ=
+ENTRY_END
+
+SCENARIO_END