diff --git a/backend/sql/sql.go b/backend/sql/sql.go
index 6f7622cbb67dbc2a4f25da150d555673a9c96950..408cc574d74536386cc9cd693a1d4161f01fdb4e 100644
--- a/backend/sql/sql.go
+++ b/backend/sql/sql.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"database/sql"
 	"errors"
+	"fmt"
 
 	_ "github.com/go-sql-driver/mysql"
 	_ "github.com/lib/pq"
@@ -32,6 +33,11 @@ func (c *sqlConfig) Valid() error {
 	if c.URI == "" {
 		return errors.New("empty uri")
 	}
+	for _, q := range []string{sqlQueryGetPublicKey, sqlQueryGetPrivateKeys} {
+		if _, ok := c.Queries[q]; !ok {
+			return fmt.Errorf("missing SQL query '%s'", q)
+		}
+	}
 	return nil
 }
 
@@ -88,6 +94,7 @@ func (b *sqlBackend) GetPrivateKeys(ctx context.Context, username string) ([][]b
 		if err := rows.Scan(&key); err != nil {
 			return nil, err
 		}
+		out = append(out, key)
 	}
 	return out, nil
 }
@@ -102,6 +109,10 @@ func (b *sqlBackend) GetPublicKey(ctx context.Context, username string) ([]byte,
 	row := tx.Stmt(b.privStmt).QueryRow(username)
 	var key []byte
 	if err := row.Scan(&key); err != nil {
+		if err == sql.ErrNoRows {
+			// No rows is not an error.
+			return nil, nil
+		}
 		return nil, err
 	}
 	return key, nil
diff --git a/dovecot/keyproxy_test.go b/dovecot/keyproxy_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..ea806c342e717ba7db268b35200ed49d63f8092c
--- /dev/null
+++ b/dovecot/keyproxy_test.go
@@ -0,0 +1,257 @@
+package dovecot
+
+import (
+	"context"
+	"database/sql"
+	"encoding/hex"
+	"fmt"
+	"io/ioutil"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"git.autistici.org/ai3/go-common/clientutil"
+	"git.autistici.org/ai3/go-common/userenckey"
+	"git.autistici.org/id/go-sso"
+	"golang.org/x/crypto/ed25519"
+	"gopkg.in/yaml.v2"
+
+	"git.autistici.org/id/keystore/backend"
+	keystore "git.autistici.org/id/keystore/server"
+)
+
+func withTestDB(t testing.TB, schema string) (func(), string) {
+	dir, err := ioutil.TempDir("", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	dbPath := filepath.Join(dir, "test.db")
+
+	db, err := sql.Open("sqlite3", dbPath)
+	if err != nil {
+		t.Fatalf("sql.Open: %v", err)
+	}
+	_, err = db.Exec(schema)
+	if err != nil {
+		t.Fatalf("sql error: %v", err)
+	}
+	db.Close()
+
+	return func() {
+		os.RemoveAll(dir)
+	}, dbPath
+}
+
+func makeMapSlice(m map[string]interface{}) yaml.MapSlice {
+	var out yaml.MapSlice
+	for k, v := range m {
+		out = append(out, yaml.MapItem{Key: k, Value: v})
+	}
+	return out
+}
+
+var (
+	testPw1 = []byte("password1")
+	testPw2 = []byte("password2")
+)
+
+// Set up a test keystore stack, including keyproxy, along with a test
+// database and valid SSO credentials.
+func createTestKeyProxy(t testing.TB) (func(), *keystore.KeyStore, *KeyLookupProxy, sso.Signer) {
+	// Set up a database with a simple schema and some real key
+	// data. The private key is then encrypted with the two valid
+	// test passwords.
+	encPub, encPriv, err := userenckey.GenerateKey()
+	if err != nil {
+		t.Fatal(err)
+	}
+	key1, err := userenckey.Encrypt(encPriv, testPw1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	key2, err := userenckey.Encrypt(encPriv, testPw2)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	cleanup, dbPath := withTestDB(t, fmt.Sprintf(`
+CREATE TABLE users (
+    email text NOT NULL,
+    public_key text
+);
+CREATE UNIQUE INDEX users_idx ON users(email);
+INSERT INTO users (email, public_key) VALUES (
+    'test@example.com', '%s'), (
+    'nokeys@example.com', NULL
+);
+CREATE TABLE keys (
+    email text NOT NULL,
+    key blob NOT NULL
+);
+INSERT INTO keys (email, key) VALUES (
+    'test@example.com', X'%s'), (
+    'test@example.com', X'%s'
+);
+`, string(encPub), hex.EncodeToString(key1), hex.EncodeToString(key2)))
+
+	// Backend configuration is shared by the keystore and keyproxy.
+	backendConfig := &backend.Config{
+		Type: "sql",
+		Params: makeMapSlice(map[string]interface{}{
+			"driver": "sqlite3",
+			"db_uri": dbPath,
+			"queries": map[string]string{
+				"get_public_key":   "SELECT public_key FROM users WHERE email = ?",
+				"get_private_keys": "SELECT key FROM keys WHERE email = ?",
+			},
+		}),
+	}
+
+	// Start a KeyStore with its own HTTP server. This requires
+	// valid SSO credentials.
+	ssoPub, ssoPriv, err := ed25519.GenerateKey(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	ssoSigner, err := sso.NewSigner(ssoPriv)
+	if err != nil {
+		t.Fatal(err)
+	}
+	pubFile := filepath.Join(filepath.Dir(dbPath), "sso.pub")
+	ioutil.WriteFile(pubFile, ssoPub, 0600) // nolint
+	ksConfig := keystore.Config{
+		Backend:          backendConfig,
+		SSOPublicKeyFile: pubFile,
+		SSOService:       "service/",
+		SSODomain:        "domain",
+	}
+	ks, err := keystore.NewKeyStore(&ksConfig)
+	if err != nil {
+		t.Fatalf("NewKeyStore: %v", err)
+	}
+	ksSrv := httptest.NewServer(keystore.NewServer(ks))
+
+	// Create the KeyProxy, connected to the KeyStore.
+	config := Config{
+		Backend: backendConfig,
+		Keystore: &clientutil.BackendConfig{
+			URL: ksSrv.URL,
+		},
+	}
+	kp, err := NewKeyLookupProxy(&config)
+	if err != nil {
+		t.Fatalf("NewKeyLookupProxy: %v", err)
+	}
+
+	return func() {
+		ksSrv.Close()
+		cleanup()
+	}, ks, kp, ssoSigner
+}
+
+func TestKeyProxy_LookupPublicKey(t *testing.T) {
+	cleanup, _, kp, _ := createTestKeyProxy(t)
+	defer cleanup()
+
+	testData := []struct {
+		username string
+		found    bool
+		error    bool
+	}{
+		{"test@example.com", true, false},
+		// User without a key -> no error.
+		{"nokeys@example.com", false, false},
+		// User does not exist -> also no error.
+		{"UNKNOWN@example.com", false, false},
+	}
+
+	for _, td := range testData {
+		_, found, err := kp.Lookup(context.Background(), "shared/userdb/"+td.username)
+		hasError := (err != nil)
+		if hasError != td.error {
+			t.Errorf("Lookup(%s): err=%v", td.username, err)
+		}
+		if found != td.found {
+			t.Errorf("Lookup(%s): found=%v, expected %v", td.username, found, td.found)
+		}
+	}
+}
+
+func TestKeyProxy_LookupPrivateKeys_FromDB(t *testing.T) {
+	cleanup, _, kp, _ := createTestKeyProxy(t)
+	defer cleanup()
+
+	testData := []struct {
+		username string
+		password string
+		found    bool
+		error    bool
+	}{
+		// Unlock the key from the database with both valid passwords.
+		{"test@example.com", string(testPw1), true, false},
+		{"test@example.com", string(testPw2), true, false},
+		// A bad password should always fail with an error, so that
+		// dovecot knows not to do anything.
+		{"test@example.com", "bad password", false, true},
+		// These users have no encryption keys, so we should *not*
+		// return an error.
+		{"nokeys@example.com", "password", false, false},
+		{"UNKNOWN@example.com", "password", false, false},
+	}
+
+	for _, td := range testData {
+		_, found, err := kp.Lookup(context.Background(), fmt.Sprintf("shared/passdb/%s/%s", td.username, td.password))
+		hasError := (err != nil)
+		if hasError != td.error {
+			t.Errorf("Lookup(%s): err=%v", td.username, err)
+		}
+		if found != td.found {
+			t.Errorf("Lookup(%s): found=%v, expected %v", td.username, found, td.found)
+		}
+	}
+}
+
+func TestKeyProxy_LookupPrivateKeys_FromKeystore(t *testing.T) {
+	cleanup, ks, kp, signer := createTestKeyProxy(t)
+	defer cleanup()
+
+	// Unlock the key in KeyStore with the correct password.
+	if err := ks.Open(context.Background(), "test@example.com", string(testPw1), 600); err != nil {
+		t.Fatalf("ks.Open: %v", err)
+	}
+
+	// Mint a good SSO token.
+	ssoToken, err := signer.Sign(sso.NewTicket("test@example.com", "service/", "domain", "", nil, 600*time.Second))
+	if err != nil {
+		t.Fatalf("sso.Sign: %v", err)
+	}
+
+	testData := []struct {
+		username string
+		password string
+		found    bool
+		error    bool
+	}{
+		// Fetch the cached key using a SSO token.
+		{"test@example.com", ssoToken, true, false},
+		// Even if we can't authenticate with the keystore,
+		// fallthru to the database should still work.
+		{"test@example.com", string(testPw1), true, false},
+		{"test@example.com", string(testPw2), true, false},
+		// A bad password should always fail.
+		{"test@example.com", "bad password", false, true},
+	}
+
+	for _, td := range testData {
+		_, found, err := kp.Lookup(context.Background(), fmt.Sprintf("shared/passdb/%s/%s", td.username, td.password))
+		hasError := (err != nil)
+		if hasError != td.error {
+			t.Errorf("Lookup(%s): err=%v", td.username, err)
+		}
+		if found != td.found {
+			t.Errorf("Lookup(%s): found=%v, expected %v", td.username, found, td.found)
+		}
+	}
+}