diff --git a/src/box/alter.cc b/src/box/alter.cc
index 5b3f7348203ce78608edd9af74712f9d08560547..680a051f79375c9754a7b20a22fa7f04f39e2a5b 100644
--- a/src/box/alter.cc
+++ b/src/box/alter.cc
@@ -78,13 +78,13 @@ access_check_ddl(const char *name, uint32_t owner_uid,
 	if (access || (owner_uid != cr->uid && cr->uid != ADMIN)) {
 		struct user *user = user_find_xc(cr->uid);
 		if (access) {
-			tnt_raise(ClientError, ER_ACCESS_DENIED,
+			tnt_raise(AccessDeniedError,
 				  priv_name(PRIV_U),
 				  schema_object_name(SC_UNIVERSE),
 				  "",
 				  user->def->name);
 		} else {
-			tnt_raise(ClientError, ER_ACCESS_DENIED,
+			tnt_raise(AccessDeniedError,
 				  priv_name(priv_type),
 				  schema_object_name(type),
 				  name,
@@ -907,7 +907,8 @@ ModifySpace::~ModifySpace() {
 
 /** DropIndex - remove an index from space. */
 
-class DropIndex: public AlterSpaceOp {
+class DropIndex: public AlterSpaceOp
+{
 public:
 	DropIndex(struct alter_space *alter, struct index_def *def_arg)
 		:AlterSpaceOp(alter), old_index_def(def_arg) {}
@@ -1059,7 +1060,8 @@ ModifyIndex::~ModifyIndex()
 }
 
 /** CreateIndex - add a new index to the space. */
-class CreateIndex: public AlterSpaceOp {
+class CreateIndex: public AlterSpaceOp
+{
 public:
 	CreateIndex(struct alter_space *alter)
 		:AlterSpaceOp(alter),
@@ -1135,7 +1137,8 @@ CreateIndex::~CreateIndex()
  * from by reading the primary key. Used when key_def of
  * an index is changed.
  */
-class RebuildIndex: public AlterSpaceOp {
+class RebuildIndex: public AlterSpaceOp
+{
 public:
 	RebuildIndex(struct alter_space *alter,
 		     struct index_def *new_index_def_arg,
@@ -2338,7 +2341,7 @@ priv_def_check(struct priv_def *priv, enum priv_type priv_type)
 	switch (priv->object_type) {
 	case SC_UNIVERSE:
 		if (grantor->def->uid != ADMIN) {
-			tnt_raise(ClientError, ER_ACCESS_DENIED,
+			tnt_raise(AccessDeniedError,
 				  priv_name(priv_type),
 				  schema_object_name(SC_UNIVERSE),
 				  name,
@@ -2350,7 +2353,7 @@ priv_def_check(struct priv_def *priv, enum priv_type priv_type)
 		struct space *space = space_cache_find_xc(priv->object_id);
 		if (space->def->uid != grantor->def->uid &&
 		    grantor->def->uid != ADMIN) {
-			tnt_raise(ClientError, ER_ACCESS_DENIED,
+			tnt_raise(AccessDeniedError,
 				  priv_name(priv_type),
 				  schema_object_name(SC_SPACE), name,
 				  grantor->def->name);
@@ -2362,7 +2365,7 @@ priv_def_check(struct priv_def *priv, enum priv_type priv_type)
 		struct func *func = func_cache_find(priv->object_id);
 		if (func->def->uid != grantor->def->uid &&
 		    grantor->def->uid != ADMIN) {
-			tnt_raise(ClientError, ER_ACCESS_DENIED,
+			tnt_raise(AccessDeniedError,
 				  priv_name(priv_type),
 				  schema_object_name(SC_FUNCTION), name,
 				  grantor->def->name);
@@ -2374,7 +2377,7 @@ priv_def_check(struct priv_def *priv, enum priv_type priv_type)
 		struct sequence *seq = sequence_cache_find(priv->object_id);
 		if (seq->def->uid != grantor->def->uid &&
 		    grantor->def->uid != ADMIN) {
-			tnt_raise(ClientError, ER_ACCESS_DENIED,
+			tnt_raise(AccessDeniedError,
 				  priv_name(priv_type),
 				  schema_object_name(SC_SEQUENCE), name,
 				  grantor->def->name);
@@ -2396,7 +2399,7 @@ priv_def_check(struct priv_def *priv, enum priv_type priv_type)
 		if (role->def->owner != grantor->def->uid &&
 		    grantor->def->uid != ADMIN &&
 		    (role->def->uid != PUBLIC || priv->access < PRIV_X)) {
-			tnt_raise(ClientError, ER_ACCESS_DENIED,
+			tnt_raise(AccessDeniedError,
 				  priv_name(priv_type),
 				  schema_object_name(SC_ROLE), name,
 				  grantor->def->name);;
diff --git a/src/box/call.cc b/src/box/call.cc
index aca87146742a397274de71065b4a837ef1fa6e9c..b597dac3ba4ffe329e77913b806ef5e99d287ccf 100644
--- a/src/box/call.cc
+++ b/src/box/call.cc
@@ -79,12 +79,12 @@ access_check_func(const char *name, uint32_t name_len, struct func **funcp)
 		struct user *user = user_find(credentials->uid);
 		if (user != NULL) {
 			if (!(access & credentials->universal_access)) {
-				diag_set(ClientError, ER_ACCESS_DENIED,
+				diag_set(AccessDeniedError,
 					 priv_name(PRIV_U),
 					 schema_object_name(SC_UNIVERSE), "",
 					 user->def->name);
 			} else {
-				diag_set(ClientError, ER_ACCESS_DENIED,
+				diag_set(AccessDeniedError,
 					 priv_name(PRIV_X),
 					 schema_object_name(SC_FUNCTION),
 					 tt_cstr(name, name_len),
diff --git a/src/box/error.cc b/src/box/error.cc
index 53ffcfbff7202936a4936d7130ef12056947cd47..647b17e6f87db3927ff225233ef64971c10ae9e4 100644
--- a/src/box/error.cc
+++ b/src/box/error.cc
@@ -99,17 +99,23 @@ static struct method_info clienterror_methods[] = {
 const struct type_info type_ClientError =
 	make_type("ClientError", &type_Exception, clienterror_methods);
 
+ClientError::ClientError(const type_info *type, const char *file, unsigned line,
+			 uint32_t errcode)
+	:Exception(type, file, line)
+{
+	m_errcode = errcode;
+	if (rmean_error)
+		rmean_collect(rmean_error, RMEAN_ERROR, 1);
+}
+
 ClientError::ClientError(const char *file, unsigned line,
 			 uint32_t errcode, ...)
-	: Exception(&type_ClientError, file, line)
+	:ClientError(&type_ClientError, file, line, errcode)
 {
-	m_errcode = errcode;
 	va_list ap;
 	va_start(ap, errcode);
 	error_vformat_msg(this, tnt_errcode_desc(m_errcode), ap);
 	va_end(ap);
-	if (rmean_error)
-		rmean_collect(rmean_error, RMEAN_ERROR, 1);
 }
 
 struct error *
@@ -165,3 +171,57 @@ BuildXlogError(const char *file, unsigned line, const char *format, ...)
 	}
 }
 
+#include "schema.h"
+#include "trigger.h"
+
+struct rlist on_access_denied = RLIST_HEAD_INITIALIZER(on_access_denied);
+
+static struct method_info accessdeniederror_methods[] = {
+	make_method(&type_AccessDeniedError, "access_type", &AccessDeniedError::access_type),
+	make_method(&type_AccessDeniedError, "object_type", &AccessDeniedError::object_type),
+	make_method(&type_AccessDeniedError, "object_name", &AccessDeniedError::object_name),
+	METHODS_SENTINEL
+};
+
+const struct type_info type_AccessDeniedError =
+	make_type("AccessDeniedError", &type_ClientError,
+		  accessdeniederror_methods);
+
+AccessDeniedError::AccessDeniedError(const char *file, unsigned int line,
+				     const char *access_type,
+				     const char *object_type,
+				     const char *object_name,
+				     const char *user_name)
+	:ClientError(&type_AccessDeniedError, file, line, ER_ACCESS_DENIED)
+{
+	error_format_msg(this, tnt_errcode_desc(m_errcode),
+			 access_type, object_type, object_name, user_name);
+
+	struct on_access_denied_ctx ctx = {access_type, object_type, object_name};
+	trigger_run(&on_access_denied, (void *) &ctx);
+	/*
+	 * We want to use ctx parameters as error parameters
+	 * later, so we have to alloc space for it.
+	 * As m_access_type and m_object_type are constant
+	 * literals they are statically  allocated. We must copy
+	 * only m_object_name.
+	 */
+	m_object_type = object_type;
+	m_access_type = access_type;
+	m_object_name = strdup(object_name);
+}
+
+struct error *
+BuildAccessDeniedError(const char *file, unsigned int line,
+		       const char *access_type, const char *object_type,
+		       const char *object_name,
+		       const char *user_name)
+{
+	try {
+		return new AccessDeniedError(file, line, access_type,
+					     object_type, object_name,
+					     user_name);
+	} catch (OutOfMemory *e) {
+		return e;
+	}
+}
diff --git a/src/box/error.h b/src/box/error.h
index 63b4a7d6c501ec680900e0b242ef7dfe4b23ac6e..c791e6c6a0b580892a4841a99b9b395cd9adc4df 100644
--- a/src/box/error.h
+++ b/src/box/error.h
@@ -39,6 +39,12 @@ extern "C" {
 struct error *
 BuildClientError(const char *file, unsigned line, uint32_t errcode, ...);
 
+struct error *
+BuildAccessDeniedError(const char *file, unsigned int line,
+		       const char *access_type, const char *object_type,
+		       const char *object_name, const char *user_name);
+
+
 /** \cond public */
 
 struct error;
@@ -125,6 +131,7 @@ box_error_set(const char *file, unsigned line, uint32_t code,
 
 extern const struct type_info type_ClientError;
 extern const struct type_info type_XlogError;
+extern const struct type_info type_AccessDeniedError;
 
 #if defined(__cplusplus)
 } /* extern "C" */
@@ -139,7 +146,8 @@ enum rmean_error_name {
 };
 extern const char *rmean_error_strings[RMEAN_ERROR_LAST];
 
-class ClientError: public Exception {
+class ClientError: public Exception
+{
 public:
 	virtual void raise()
 	{
@@ -159,9 +167,13 @@ class ClientError: public Exception {
 	static uint32_t get_errcode(const struct error *e);
 	/* client errno code */
 	int m_errcode;
+protected:
+	ClientError(const type_info *type, const char *file, unsigned line,
+		    uint32_t errcode);
 };
 
-class LoggedError: public ClientError {
+class LoggedError: public ClientError
+{
 public:
 	template <typename ... Args>
 	LoggedError(const char *file, unsigned line, uint32_t errcode, Args ... args)
@@ -172,6 +184,49 @@ class LoggedError: public ClientError {
 	}
 };
 
+/**
+ * A special type of exception which must be used
+ * for all access denied errors, since it invokes audit triggers.
+ */
+class AccessDeniedError: public ClientError
+{
+public:
+	AccessDeniedError(const char *file, unsigned int line,
+			  const char *access_type, const char *object_type,
+			  const char *object_name, const char *user_name);
+
+	~AccessDeniedError()
+	{
+		free(m_object_name);
+	}
+
+	const char *
+	object_type()
+	{
+		return m_object_type;
+	}
+
+	const char *
+	object_name()
+	{
+		return m_object_name?:"(nil)";
+	}
+
+	const char *
+	access_type()
+	{
+		return m_access_type;
+	}
+
+private:
+	/** Type of object the required access was denied to */
+	const char *m_object_type;
+	/** Name of object the required access was denied to */
+	char *m_object_name;
+	/** Type of declined access */
+	const char *m_access_type;
+};
+
 /**
  * XlogError is raised when there is an error with contents
  * of the data directory or a log file. A special subclass
diff --git a/src/box/lua/session.c b/src/box/lua/session.c
index 54baca1e563f86f0324bdb82049395f9f8e7a766..0bdfb8c77a14504821cdcdbf5fb608aac81931ae 100644
--- a/src/box/lua/session.c
+++ b/src/box/lua/session.c
@@ -40,6 +40,7 @@
 #include "box/box.h"
 #include "box/session.h"
 #include "box/user.h"
+#include "box/schema.h"
 
 static const char *sessionlib_name = "box.session";
 
@@ -344,6 +345,27 @@ lbox_session_run_on_auth(struct lua_State *L)
 	return 0;
 }
 
+static int
+lbox_push_on_access_denied_event(struct lua_State *L, void *event)
+{
+	struct on_access_denied_ctx *ctx = (struct on_access_denied_ctx *) event;
+	lua_pushstring(L, ctx->access_type);
+	lua_pushstring(L, ctx->object_type);
+	lua_pushstring(L, ctx->object_name);
+	return 3;
+}
+
+/**
+ * Sets trigger on_access_denied.
+ * For test purposes only.
+ */
+static int
+lbox_session_on_access_denied(struct lua_State *L)
+{
+	return lbox_trigger_reset(L, 2, &on_access_denied,
+				  lbox_push_on_access_denied_event);
+}
+
 void
 session_storage_cleanup(int sid)
 {
@@ -403,6 +425,7 @@ box_lua_session_init(struct lua_State *L)
 		{"on_connect", lbox_session_on_connect},
 		{"on_disconnect", lbox_session_on_disconnect},
 		{"on_auth", lbox_session_on_auth},
+		{"on_access_denied", lbox_session_on_access_denied},
 		{NULL, NULL}
 	};
 	luaL_register_module(L, sessionlib_name, sessionlib);
diff --git a/src/box/schema.h b/src/box/schema.h
index 7a1cbbf99be24381e9d8c1b823fd04524299295a..56f39b3fe084e8a2e17174e20531c7bc7ea2640d 100644
--- a/src/box/schema.h
+++ b/src/box/schema.h
@@ -219,4 +219,21 @@ extern struct rlist on_alter_space;
  */
 extern struct rlist on_alter_sequence;
 
+/**
+ * Triggers fired after access denied error is created.
+ */
+extern struct rlist on_access_denied;
+
+/**
+ * Context passed to on_access_denied trigger.
+ */
+struct on_access_denied_ctx {
+	/** Type of declined access */
+	const char *access_type;
+	/** Type of object the required access was denied to */
+	const char *object_type;
+	/** Name of object the required access was denied to */
+	const char *object_name;
+};
+
 #endif /* INCLUDES_TARANTOOL_BOX_SCHEMA_H */
diff --git a/src/box/sequence.c b/src/box/sequence.c
index b549e09d5aa9ccf7f54f56476f2085b3e5b3091e..0f6a8ca974e1af94978a2920b78fc568c167fde8 100644
--- a/src/box/sequence.c
+++ b/src/box/sequence.c
@@ -256,13 +256,12 @@ access_check_sequence(struct sequence *seq)
 		struct user *user = user_find(cr->uid);
 		if (user != NULL) {
 			if (!(cr->universal_access & PRIV_U)) {
-				diag_set(ClientError, ER_ACCESS_DENIED,
+				diag_set(AccessDeniedError,
 					 priv_name(PRIV_U),
 					 schema_object_name(SC_UNIVERSE), "",
 					 user->def->name);
 			} else {
-				diag_set(ClientError,
-					 ER_ACCESS_DENIED,
+				diag_set(AccessDeniedError,
 					 priv_name(access),
 					 schema_object_name(SC_SEQUENCE),
 					 seq->def->name, user->def->name);
diff --git a/src/box/session.cc b/src/box/session.cc
index 758e9236790ff8966c749fbbc69f9750f51498be..cb31cc5fd6b5e22bab566b3cfff3da4799b50be0 100644
--- a/src/box/session.cc
+++ b/src/box/session.cc
@@ -241,7 +241,7 @@ access_check_session(struct user *user)
 	 * as current_user is not assigned yet
 	 */
 	if (!(universe.access[user->auth_token].effective & PRIV_S)) {
-		diag_set(ClientError, ER_ACCESS_DENIED, priv_name(PRIV_S),
+		diag_set(AccessDeniedError, priv_name(PRIV_S),
 			 schema_object_name(SC_UNIVERSE), "",
 			 user->def->name);
 		return -1;
@@ -271,7 +271,7 @@ access_check_universe(user_access_t access)
 		struct user *user = user_find_xc(credentials->uid);
 		int denied_access = access & ((credentials->universal_access
 					       & access) ^ access);
-		tnt_raise(ClientError, ER_ACCESS_DENIED,
+		tnt_raise(AccessDeniedError,
 			 priv_name(denied_access),
 			 schema_object_name(SC_UNIVERSE), "",
 			 user->def->name);
diff --git a/src/box/space.c b/src/box/space.c
index 212aa1cd779aa7df3bb7913cd9c44865ee2e531d..c02eb886326d732fe5129900d44965c5546ae647 100644
--- a/src/box/space.c
+++ b/src/box/space.c
@@ -66,13 +66,12 @@ access_check_space(struct space *space, user_access_t access)
 		struct user *user = user_find(cr->uid);
 		if (user != NULL) {
 			if (!(cr->universal_access & PRIV_U)) {
-				diag_set(ClientError, ER_ACCESS_DENIED,
+				diag_set(AccessDeniedError,
 					 priv_name(PRIV_U),
 					 schema_object_name(SC_UNIVERSE), "",
 					 user->def->name);
 			} else {
-				diag_set(ClientError,
-					 ER_ACCESS_DENIED,
+				diag_set(AccessDeniedError,
 					 priv_name(access),
 					 schema_object_name(SC_SPACE),
 					 space->def->name, user->def->name);
diff --git a/test/box/access.result b/test/box/access.result
index c8cc9f09238ff33163ba1580b0142f034ed2601c..d0beb0451a3721c78cb07b97d658f1ac68cb5341 100644
--- a/test/box/access.result
+++ b/test/box/access.result
@@ -1117,3 +1117,166 @@ box.schema.func.create('test')
 box.session.su('admin')
 ---
 ...
+--
+-- gh-2911 on_access_denied trigger
+--
+obj_type = nil
+---
+...
+obj_name = nil
+---
+...
+op_type = nil
+---
+...
+euid = nil
+---
+...
+auid = nil
+---
+...
+function access_denied_trigger(op, type, name) obj_type = type; obj_name = name; op_type = op end
+---
+...
+function uid() euid = box.session.euid(); auid = box.session.uid() end
+---
+...
+_ = box.session.on_access_denied(access_denied_trigger)
+---
+...
+_ = box.session.on_access_denied(uid)
+---
+...
+s = box.schema.space.create('admin_space', {engine="vinyl"})
+---
+...
+seq = box.schema.sequence.create('test_sequence')
+---
+...
+index = s:create_index('primary', {type = 'tree', parts = {1, 'unsigned'}})
+---
+...
+box.schema.user.create('test_user', {password="pass"})
+---
+...
+box.session.su("test_user")
+---
+...
+s:select{}
+---
+- error: Read access to space 'admin_space' is denied for user 'test_user'
+...
+obj_type, obj_name, op_type
+---
+- space
+- admin_space
+- Read
+...
+euid, auid
+---
+- 32
+- 32
+...
+seq:set(1)
+---
+- error: Write access to sequence 'test_sequence' is denied for user 'test_user'
+...
+obj_type, obj_name, op_type
+---
+- sequence
+- test_sequence
+- Write
+...
+euid, auid
+---
+- 32
+- 32
+...
+box.session.su("admin")
+---
+...
+c = (require 'net.box').connect(LISTEN.host, LISTEN.service, {user="test_user", password="pass"})
+---
+...
+function func() end
+---
+...
+st, e = pcall(c.call, c, func)
+---
+...
+obj_type, op_type
+---
+- function
+- Execute
+...
+euid, auid
+---
+- 32
+- 32
+...
+obj_name:match("function")
+---
+- function
+...
+box.schema.user.revoke("test_user", "usage", "universe")
+---
+...
+box.session.su("test_user")
+---
+...
+st, e = pcall(s.select, s, {})
+---
+...
+e = e:unpack()
+---
+...
+e.type, e.access_type, e.object_type, e.message
+---
+- AccessDeniedError
+- Usage
+- universe
+- Usage access to universe '' is denied for user 'test_user'
+...
+obj_type, obj_name, op_type
+---
+- universe
+- 
+- Usage
+...
+euid, auid
+---
+- 32
+- 32
+...
+box.session.su("admin")
+---
+...
+box.schema.user.revoke("test_user", "session", "universe")
+---
+...
+c = (require 'net.box').connect(LISTEN.host, LISTEN.service, {user="test_user", password="pass"})
+---
+...
+obj_type, obj_name, op_type
+---
+- universe
+- 
+- Session
+...
+euid, auid
+---
+- 0
+- 0
+...
+box.session.on_access_denied(nil, access_denied_trigger)
+---
+...
+box.session.on_access_denied(nil, uid)
+---
+...
+box.schema.user.drop("test_user")
+---
+...
+s:drop()
+---
+...
diff --git a/test/box/access.test.lua b/test/box/access.test.lua
index 8f366ddde729a88dd1b44fdf1ff5d42fdd00fb3a..0d5690a4251db99c6008952d336c310d5d4e3f76 100644
--- a/test/box/access.test.lua
+++ b/test/box/access.test.lua
@@ -426,3 +426,50 @@ box.schema.space.create('test')
 box.schema.user.create('test')
 box.schema.func.create('test')
 box.session.su('admin')
+
+--
+-- gh-2911 on_access_denied trigger
+--
+obj_type = nil
+obj_name = nil
+op_type = nil
+euid = nil
+auid = nil
+function access_denied_trigger(op, type, name) obj_type = type; obj_name = name; op_type = op end
+function uid() euid = box.session.euid(); auid = box.session.uid() end
+_ = box.session.on_access_denied(access_denied_trigger)
+_ = box.session.on_access_denied(uid)
+s = box.schema.space.create('admin_space', {engine="vinyl"})
+seq = box.schema.sequence.create('test_sequence')
+index = s:create_index('primary', {type = 'tree', parts = {1, 'unsigned'}})
+box.schema.user.create('test_user', {password="pass"})
+box.session.su("test_user")
+s:select{}
+obj_type, obj_name, op_type
+euid, auid
+seq:set(1)
+obj_type, obj_name, op_type
+euid, auid
+box.session.su("admin")
+c = (require 'net.box').connect(LISTEN.host, LISTEN.service, {user="test_user", password="pass"})
+function func() end
+st, e = pcall(c.call, c, func)
+obj_type, op_type
+euid, auid
+obj_name:match("function")
+box.schema.user.revoke("test_user", "usage", "universe")
+box.session.su("test_user")
+st, e = pcall(s.select, s, {})
+e = e:unpack()
+e.type, e.access_type, e.object_type, e.message
+obj_type, obj_name, op_type
+euid, auid
+box.session.su("admin")
+box.schema.user.revoke("test_user", "session", "universe")
+c = (require 'net.box').connect(LISTEN.host, LISTEN.service, {user="test_user", password="pass"})
+obj_type, obj_name, op_type
+euid, auid
+box.session.on_access_denied(nil, access_denied_trigger)
+box.session.on_access_denied(nil, uid)
+box.schema.user.drop("test_user")
+s:drop()