diff --git a/actions.go b/actions.go
index 852fa017f92a319fc095eda194497110035eae8b..3914cca5dfae5062fd329dc41ef8af0b840adb2e 100644
--- a/actions.go
+++ b/actions.go
@@ -39,6 +39,8 @@ type Backend interface {
 	DeleteApplicationSpecificPassword(context.Context, *User, string) error
 	SetUserTOTPSecret(context.Context, *User, string) error
 	DeleteUserTOTPSecret(context.Context, *User) error
+
+	HasAnyResource(context.Context, []string) (bool, error)
 }
 
 // AccountService implements the business logic and high-level
@@ -50,16 +52,37 @@ type AccountService struct {
 	ssoService    string
 	ssoGroups     []string
 	ssoAdminGroup string
+
+	dataValidators      map[string]ValidatorFunc
+	adminDataValidators map[string]ValidatorFunc
+}
+
+func NewAccountService(backend Backend, config *Config) (*AccountService, error) {
+	ssoValidator, err := config.ssoValidator()
+	if err != nil {
+		return nil, err
+	}
+
+	return newAccountServiceWithSSO(backend, config, ssoValidator), nil
 }
 
-func NewAccountService(backend Backend, ssoValidator sso.Validator, ssoService string, ssoGroups []string, ssoAdminGroup string) *AccountService {
-	return &AccountService{
+func newAccountServiceWithSSO(backend Backend, config *Config, ssoValidator sso.Validator) *AccountService {
+	s := &AccountService{
 		backend:       backend,
 		validator:     ssoValidator,
-		ssoService:    ssoService,
-		ssoGroups:     ssoGroups,
-		ssoAdminGroup: ssoAdminGroup,
+		ssoService:    config.SSO.Service,
+		ssoGroups:     config.SSO.Groups,
+		ssoAdminGroup: config.SSO.AdminGroup,
 	}
+
+	validationConfig := config.validationConfig()
+	domainBackend := config.domainBackend()
+	s.dataValidators = map[string]ValidatorFunc{
+		ResourceTypeEmail:       validHostedEmail(validationConfig, domainBackend, backend),
+		ResourceTypeMailingList: validHostedMailingList(validationConfig, domainBackend, backend),
+	}
+
+	return s
 }
 
 func (s *AccountService) isAdmin(tkt *sso.Ticket) bool {
@@ -77,6 +100,29 @@ var (
 	ErrResourceNotFound = errors.New("resource not found")
 )
 
+func (s *AccountService) authorizeAdmin(ctx context.Context, username, ssoToken string) (*User, error) {
+	// Validate the SSO ticket.
+	tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
+	if err != nil {
+		return nil, newAuthError(err)
+	}
+
+	// Requests are allowed if the SSO ticket corresponds to an admin, or if
+	// it identifies the same user that we're querying.
+	if !s.isAdmin(tkt) {
+		return nil, newAuthError(ErrUnauthorized)
+	}
+
+	user, err := s.backend.GetUser(ctx, username)
+	if err != nil {
+		return nil, newBackendError(err)
+	}
+	if user == nil {
+		return nil, ErrUserNotFound
+	}
+	return user, nil
+}
+
 func (s *AccountService) authorizeUser(ctx context.Context, username, ssoToken string) (*User, error) {
 	// First, check that the username matches the SSO ticket
 	// username (or that the SSO ticket has admin permissions).
@@ -101,19 +147,38 @@ func (s *AccountService) authorizeUser(ctx context.Context, username, ssoToken s
 	return user, nil
 }
 
+// Extended version of authorizeUser that also directly checks the
+// user password. Used for account-privileged operations related to
+// credential manipulation.
+func (s *AccountService) authorizeUserWithPassword(ctx context.Context, username, ssoToken, password string) (*User, error) {
+	// TODO: call out to the auth-server?
+	return s.authorizeUser(ctx, username, ssoToken)
+}
+
+// RequestBase contains parameters shared by all request types.
 type RequestBase struct {
 	Username string `json:"username"`
 	SSO      string `json:"sso"`
 }
 
+// PrivilegedRequestBase extends RequestBase with the user password,
+// for privileged endpoints.
+type PrivilegedRequestBase struct {
+	RequestBase
+	CurPassword string `json:"cur_password"`
+}
+
 type GetUserRequest struct {
 	RequestBase
 }
 
+// GetUser returns public information about a user.
 func (s *AccountService) GetUser(ctx context.Context, req *GetUserRequest) (*User, error) {
 	return s.authorizeUser(ctx, req.Username, req.SSO)
 }
 
+// setResourceStatus sets the status of a single resource (shared
+// logic between enable / disable resource methods).
 func (s *AccountService) setResourceStatus(ctx context.Context, username, resourceID, status string) error {
 	r, err := s.backend.GetResource(ctx, username, resourceID)
 	if err != nil {
@@ -134,6 +199,7 @@ type DisableResourceRequest struct {
 	ResourceID string `json:"resource_id"`
 }
 
+// DisableResource disables a resource belonging to the user.
 func (s *AccountService) DisableResource(ctx context.Context, req *DisableResourceRequest) error {
 	if _, err := s.authorizeUser(ctx, req.Username, req.SSO); err != nil {
 		return err
@@ -146,6 +212,7 @@ type EnableResourceRequest struct {
 	ResourceID string `json:"resource_id"`
 }
 
+// EnableResource enables a resource belonging to the user.
 func (s *AccountService) EnableResource(ctx context.Context, req *EnableResourceRequest) error {
 	if _, err := s.authorizeUser(ctx, req.Username, req.SSO); err != nil {
 		return err
@@ -154,23 +221,21 @@ func (s *AccountService) EnableResource(ctx context.Context, req *EnableResource
 }
 
 type ChangeUserPasswordRequest struct {
-	RequestBase
-	CurPassword string `json:"cur_password"`
-	Password    string `json:"password"`
+	PrivilegedRequestBase
+	Password string `json:"password"`
 }
 
 func (r *ChangeUserPasswordRequest) Validate() error {
-	if r.CurPassword == "" {
-		return errors.New("empty 'cur_password' attribute")
-	}
 	if r.Password == "" {
 		return errors.New("empty 'password' attribute")
 	}
 	return nil
 }
 
+// ChangeUserPassword updates a user's password. It will also take
+// care of re-encrypting the user encryption key, if present.
 func (s *AccountService) ChangeUserPassword(ctx context.Context, req *ChangeUserPasswordRequest) error {
-	user, err := s.authorizeUser(ctx, req.Username, req.SSO)
+	user, err := s.authorizeUserWithPassword(ctx, req.Username, req.SSO, req.CurPassword)
 	if err != nil {
 		return err
 	}
@@ -286,16 +351,12 @@ func reEncryptUserKeys(keys []*UserEncryptionKey, curPassword, newPassword, keyI
 }
 
 type CreateApplicationSpecificPasswordRequest struct {
-	RequestBase
-	CurPassword string `json:"cur_password"` // User password.
-	Service     string `json:"service"`
-	Comment     string `json:"comment"`
+	PrivilegedRequestBase
+	Service string `json:"service"`
+	Comment string `json:"comment"`
 }
 
 func (r *CreateApplicationSpecificPasswordRequest) Validate() error {
-	if r.CurPassword == "" {
-		return errors.New("empty 'cur_password' attribute")
-	}
 	if r.Service == "" {
 		return errors.New("empty 'service' attribute")
 	}
@@ -306,8 +367,10 @@ type CreateApplicationSpecificPasswordResponse struct {
 	Password string `json:"password"`
 }
 
+// CreateApplicationSpecificPassword will generate a new
+// application-specific password for the given service.
 func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context, req *CreateApplicationSpecificPasswordRequest) (*CreateApplicationSpecificPasswordResponse, error) {
-	user, err := s.authorizeUser(ctx, req.Username, req.SSO)
+	user, err := s.authorizeUserWithPassword(ctx, req.Username, req.SSO, req.CurPassword)
 	if err != nil {
 		return nil, err
 	}
@@ -355,6 +418,8 @@ type DeleteApplicationSpecificPasswordRequest struct {
 	AspID string `json:"asp_id"`
 }
 
+// DeleteApplicationSpecificPassword destroys an application-specific
+// password, identified by its unique ID.
 func (s *AccountService) DeleteApplicationSpecificPassword(ctx context.Context, req *DeleteApplicationSpecificPasswordRequest) error {
 	user, err := s.authorizeUser(ctx, req.Username, req.SSO)
 	if err != nil {
@@ -397,6 +462,9 @@ func (r *ChangeResourcePasswordRequest) Validate() error {
 	return nil
 }
 
+// ChangeResourcePassword modifies the password associated with a
+// specific resource. Resources that do not support this method should
+// return an error from the backend.
 func (s *AccountService) ChangeResourcePassword(ctx context.Context, req *ChangeResourcePasswordRequest) error {
 	_, err := s.authorizeUser(ctx, req.Username, req.SSO)
 	if err != nil {
@@ -432,8 +500,12 @@ type MoveResourceResponse struct {
 	MovedIDs []string `json:"moved_ids"`
 }
 
+// MoveResource is an administrative operation to move resources
+// between shards. Resources that are part of a group are moved all at
+// once regardless of which individual ResourceID is provided as long
+// as it belongs to the group.
 func (s *AccountService) MoveResource(ctx context.Context, req *MoveResourceRequest) (*MoveResourceResponse, error) {
-	user, err := s.authorizeUser(ctx, req.Username, req.SSO)
+	user, err := s.authorizeAdmin(ctx, req.Username, req.SSO)
 	if err != nil {
 		return nil, err
 	}
@@ -464,29 +536,49 @@ func (s *AccountService) MoveResource(ctx context.Context, req *MoveResourceRequ
 
 type EnableOTPRequest struct {
 	RequestBase
+	TOTPSecret string `json:"totp_secret"`
+}
+
+func (r *EnableOTPRequest) Validate() error {
+	// TODO: the length here is bogus, replace with real value.
+	if r.TOTPSecret != "" && len(r.TOTPSecret) != 32 {
+		return errors.New("bad totp_secret value")
+	}
+	return nil
 }
 
 type EnableOTPResponse struct {
 	TOTPSecret string `json:"totp_secret"`
 }
 
+// EnableOTP enables OTP-based two-factor authentication for a
+// user. The caller can generate the TOTP secret itself if needed
+// (useful for UX that confirms that the user is able to login first),
+// or it can let the server generate a new secret by passing an empty
+// totp_secret.
 func (s *AccountService) EnableOTP(ctx context.Context, req *EnableOTPRequest) (*EnableOTPResponse, error) {
 	user, err := s.authorizeUser(ctx, req.Username, req.SSO)
 	if err != nil {
 		return nil, err
 	}
 
+	if err = req.Validate(); err != nil {
+		return nil, newRequestError(err)
+	}
+
 	// Replace or initialize the TOTP secret.
-	totpSecret, err := generateTOTPSecret()
-	if err != nil {
-		return nil, err
+	if req.TOTPSecret == "" {
+		req.TOTPSecret, err = generateTOTPSecret()
+		if err != nil {
+			return nil, err
+		}
 	}
-	if err := s.backend.SetUserTOTPSecret(ctx, user, totpSecret); err != nil {
+	if err := s.backend.SetUserTOTPSecret(ctx, user, req.TOTPSecret); err != nil {
 		return nil, newBackendError(err)
 	}
 
 	return &EnableOTPResponse{
-		TOTPSecret: totpSecret,
+		TOTPSecret: req.TOTPSecret,
 	}, nil
 }
 
@@ -494,6 +586,7 @@ type DisableOTPRequest struct {
 	RequestBase
 }
 
+// DisableOTP disables two-factor authentication for a user.
 func (s *AccountService) DisableOTP(ctx context.Context, req *DisableOTPRequest) error {
 	user, err := s.authorizeUser(ctx, req.Username, req.SSO)
 	if err != nil {
diff --git a/actions_test.go b/actions_test.go
index e875b36517c28840a6740727d2df48e534ba6092..4c9443da21cb1657efb952163b96cb27442dc657 100644
--- a/actions_test.go
+++ b/actions_test.go
@@ -66,6 +66,10 @@ func (b *fakeBackend) DeleteUserTOTPSecret(_ context.Context, user *User) error
 	return nil
 }
 
+func (b *fakeBackend) HasAnyResource(_ context.Context, rsrcs []string) (bool, error) {
+	return false, nil
+}
+
 const testAdminGroupName = "admins"
 
 type fakeValidator struct {
@@ -110,8 +114,16 @@ func createFakeBackend() *fakeBackend {
 	return fb
 }
 
+func testConfig() *Config {
+	var c Config
+	c.SSO.Domain = "mydomain"
+	c.SSO.Service = "service/"
+	c.SSO.AdminGroup = testAdminGroupName
+	return &c
+}
+
 func TestService_GetUser(t *testing.T) {
-	svc := NewAccountService(createFakeBackend(), &fakeValidator{}, "service/", nil, testAdminGroupName)
+	svc := newAccountServiceWithSSO(createFakeBackend(), testConfig(), &fakeValidator{})
 
 	req := &GetUserRequest{
 		RequestBase: RequestBase{
@@ -129,7 +141,7 @@ func TestService_GetUser(t *testing.T) {
 }
 
 func TestService_Auth(t *testing.T) {
-	svc := NewAccountService(createFakeBackend(), &fakeValidator{"adminuser"}, "service/", nil, "admins")
+	svc := newAccountServiceWithSSO(createFakeBackend(), testConfig(), &fakeValidator{"adminuser"})
 
 	for _, td := range []struct {
 		sso        string
@@ -160,15 +172,17 @@ func TestService_Auth(t *testing.T) {
 
 func TestService_ChangePassword(t *testing.T) {
 	fb := createFakeBackend()
-	svc := NewAccountService(fb, &fakeValidator{}, "service/", nil, testAdminGroupName)
+	svc := newAccountServiceWithSSO(fb, testConfig(), &fakeValidator{})
 
 	req := &ChangeUserPasswordRequest{
-		RequestBase: RequestBase{
-			Username: "testuser",
-			SSO:      "testuser",
+		PrivilegedRequestBase: PrivilegedRequestBase{
+			RequestBase: RequestBase{
+				Username: "testuser",
+				SSO:      "testuser",
+			},
+			CurPassword: "cur",
 		},
-		CurPassword: "cur",
-		Password:    "password",
+		Password: "password",
 	}
 	err := svc.ChangeUserPassword(context.TODO(), req)
 	if err != nil {
@@ -176,6 +190,9 @@ func TestService_ChangePassword(t *testing.T) {
 	}
 
 	if _, ok := fb.passwords["testuser"]; !ok {
-		t.Fatal("password was not set on the backend")
+		t.Error("password was not set on the backend")
+	}
+	if len(fb.encryptionKeys["testuser"]) != 1 {
+		t.Errorf("no encryption keys were set")
 	}
 }
diff --git a/backend/model.go b/backend/model.go
index 8537fd9197a39040ca6a1aaa332e8f0b587756a6..d876ad2352271c30132f4788f022b16066c67392 100644
--- a/backend/model.go
+++ b/backend/model.go
@@ -23,11 +23,17 @@ type ldapConn interface {
 }
 
 // LDAPBackend is the interface to an LDAP-backed user database.
+//
+// We keep a set of LDAP queries for each resource type, each having a
+// "resource" query to return a specific resource belonging to a user,
+// and a "presence" query that checks for existence of a resource for
+// all users.
 type LDAPBackend struct {
 	conn                ldapConn
 	userQuery           *queryConfig
 	userResourceQueries []*queryConfig
 	resourceQueries     map[string]*queryConfig
+	presenceQueries     map[string]*queryConfig
 }
 
 const ldapPoolSize = 20
@@ -86,6 +92,33 @@ func NewLDAPBackend(uri, bindDN, bindPw, base string) (*LDAPBackend, error) {
 				Scope:  "one",
 			}),
 		},
+		presenceQueries: map[string]*queryConfig{
+			accountserver.ResourceTypeEmail: mustCompileQueryConfig(&queryConfig{
+				Base:   "ou=People," + base,
+				Filter: "(&(objectClass=virtualMailUser)(mail=${resource}))",
+				Scope:  "sub",
+			}),
+			accountserver.ResourceTypeWebsite: mustCompileQueryConfig(&queryConfig{
+				Base:   "ou=People," + base,
+				Filter: "(|(&(objectClass=subSite)(alias=${resource}))(&(objectClass=virtualHost)(cn=${resource})))",
+				Scope:  "sub",
+			}),
+			accountserver.ResourceTypeDAV: mustCompileQueryConfig(&queryConfig{
+				Base:   "ou=People," + base,
+				Filter: "(&(objectClass=ftpAccount)(ftpname=${resource}))",
+				Scope:  "sub",
+			}),
+			accountserver.ResourceTypeDatabase: mustCompileQueryConfig(&queryConfig{
+				Base:   "ou=People," + base,
+				Filter: "(&(objectClass=dbMysql)(dbname=${resource}))",
+				Scope:  "sub",
+			}),
+			accountserver.ResourceTypeMailingList: mustCompileQueryConfig(&queryConfig{
+				Base:   "ou=Lists," + base,
+				Filter: "(&(objectClass=mailingList)(listName=${resource}))",
+				Scope:  "one",
+			}),
+		},
 	}, nil
 }
 
@@ -548,6 +581,41 @@ func parseResourceID(resourceID string) (string, string) {
 	return parts[0], parts[1]
 }
 
+func (b *LDAPBackend) hasResource(ctx context.Context, resourceID string) (bool, error) {
+	resourceType, resourceName := parseResourceID(resourceID)
+	query, ok := b.presenceQueries[resourceType]
+	if !ok {
+		return false, errors.New("unsupported resource type")
+	}
+
+	// Make a quick LDAP search that only fetches the DN attribute.
+	result, err := b.conn.Search(ctx, query.searchRequest(map[string]string{
+		"resource": resourceName,
+		"type":     resourceType,
+	}, []string{"dn"}))
+	if err != nil {
+		if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
+			return false, nil
+		}
+		return false, err
+	}
+	if len(result.Entries) == 0 {
+		return false, nil
+	}
+	return true, nil
+}
+
+// HasAnyResource returns true if any of the specified resources exists.
+func (b *LDAPBackend) HasAnyResource(ctx context.Context, resourceIDs []string) (bool, error) {
+	for _, resourceID := range resourceIDs {
+		has, err := b.hasResource(ctx, resourceID)
+		if err != nil || has {
+			return has, err
+		}
+	}
+	return false, nil
+}
+
 // GetResource returns a ResourceWrapper, as part of a read-modify-update transaction.
 func (b *LDAPBackend) GetResource(ctx context.Context, username, resourceID string) (*accountserver.Resource, error) {
 	resourceType, resourceName := parseResourceID(resourceID)
diff --git a/cmd/accountserver/main.go b/cmd/accountserver/main.go
index b9d116614171660d8df6a1e585c177c26be1bc3f..a1672b4bfdd0218d9c8ca04e9caf4f1c5ce79f8e 100644
--- a/cmd/accountserver/main.go
+++ b/cmd/accountserver/main.go
@@ -9,7 +9,6 @@ import (
 
 	"git.autistici.org/ai3/accountserver"
 	"git.autistici.org/ai3/go-common/serverutil"
-	"git.autistici.org/id/go-sso"
 	"gopkg.in/yaml.v2"
 
 	"git.autistici.org/ai3/accountserver/backend"
@@ -18,10 +17,10 @@ import (
 
 var (
 	addr       = flag.String("addr", ":4040", "tcp `address` to listen on")
-	configFile = flag.String("config", "/etc/authserver/config.yml", "configuration `file`")
+	configFile = flag.String("config", "/etc/accountserver/config.yml", "configuration `file`")
 )
 
-type Config struct {
+type config struct {
 	LDAP struct {
 		URI        string `yaml:"uri"`
 		BindDN     string `yaml:"bind_dn"`
@@ -29,17 +28,11 @@ type Config struct {
 		BindPwFile string `yaml:"bind_pw_file"`
 		BaseDN     string `yaml:"base_dn"`
 	} `yaml:"ldap"`
-	SSO struct {
-		PublicKeyFile string   `yaml:"public_key"`
-		Domain        string   `yaml:"domain"`
-		Service       string   `yaml:"service"`
-		Groups        []string `yaml:"groups"`
-		AdminGroup    string   `yaml:"admin_group"`
-	} `yaml:"sso"`
-	ServerConfig *serverutil.ServerConfig `yaml:"http_server"`
+	AccountServerConfig *accountserver.Config    `yaml:",inline"`
+	ServerConfig        *serverutil.ServerConfig `yaml:"http_server"`
 }
 
-func (c *Config) Validate() error {
+func (c *config) validate() error {
 	if c.LDAP.URI == "" {
 		return errors.New("empty ldap.uri")
 	}
@@ -49,39 +42,39 @@ func (c *Config) Validate() error {
 	if (c.LDAP.BindPwFile == "" && c.LDAP.BindPw == "") || (c.LDAP.BindPwFile != "" && c.LDAP.BindPw != "") {
 		return errors.New("only one of ldap.bind_pw_file or ldap.bind_pw must be set")
 	}
-	if c.SSO.PublicKeyFile == "" {
+	if c.AccountServerConfig.SSO.PublicKeyFile == "" {
 		return errors.New("empty sso.public_key")
 	}
-	if c.SSO.Domain == "" {
+	if c.AccountServerConfig.SSO.Domain == "" {
 		return errors.New("empty sso.domain")
 	}
-	if c.SSO.Service == "" {
+	if c.AccountServerConfig.SSO.Service == "" {
 		return errors.New("empty sso.service")
 	}
 	return nil
 }
 
-func loadConfig(path string) (*Config, error) {
+func loadConfig(path string) (*config, error) {
 	// Read YAML config.
 	data, err := ioutil.ReadFile(path)
 	if err != nil {
 		return nil, err
 	}
-	var config Config
-	if err := yaml.Unmarshal(data, &config); err != nil {
+	var c config
+	if err := yaml.Unmarshal(data, &c); err != nil {
 		return nil, err
 	}
-	if err := config.Validate(); err != nil {
+	if err := c.validate(); err != nil {
 		return nil, err
 	}
-	return &config, nil
+	return &c, nil
 }
 
-func getBindPw(config *Config) (string, error) {
+func getBindPw(c *config) (string, error) {
 	// Read the bind password.
-	bindPw := config.LDAP.BindPw
-	if config.LDAP.BindPwFile != "" {
-		pwData, err := ioutil.ReadFile(config.LDAP.BindPwFile)
+	bindPw := c.LDAP.BindPw
+	if c.LDAP.BindPwFile != "" {
+		pwData, err := ioutil.ReadFile(c.LDAP.BindPwFile)
 		if err != nil {
 			return "", err
 		}
@@ -91,14 +84,6 @@ func getBindPw(config *Config) (string, error) {
 	return bindPw, nil
 }
 
-func initSSO(config *Config) (sso.Validator, error) {
-	pkey, err := ioutil.ReadFile(config.SSO.PublicKeyFile)
-	if err != nil {
-		return nil, err
-	}
-	return sso.NewValidator(pkey, config.SSO.Domain)
-}
-
 func main() {
 	log.SetFlags(0)
 	flag.Parse()
@@ -123,17 +108,10 @@ func main() {
 		log.Fatal(err)
 	}
 
-	validator, err := initSSO(config)
+	service, err := accountserver.NewAccountService(be, config.AccountServerConfig)
 	if err != nil {
 		log.Fatal(err)
 	}
-	service := accountserver.NewAccountService(
-		be,
-		validator,
-		config.SSO.Service,
-		config.SSO.Groups,
-		config.SSO.AdminGroup,
-	)
 
 	as := server.New(service)
 
diff --git a/config.go b/config.go
new file mode 100644
index 0000000000000000000000000000000000000000..42d95f3a414c215e3e7e070398393b5b211e21f3
--- /dev/null
+++ b/config.go
@@ -0,0 +1,42 @@
+package accountserver
+
+import (
+	"io/ioutil"
+
+	"git.autistici.org/id/go-sso"
+)
+
+type Config struct {
+	ForbiddenUsernames []string            `yaml:"forbidden_usernames"`
+	AvailableDomains   map[string][]string `yaml:"available_domains"`
+
+	SSO struct {
+		PublicKeyFile string   `yaml:"public_key"`
+		Domain        string   `yaml:"domain"`
+		Service       string   `yaml:"service"`
+		Groups        []string `yaml:"groups"`
+		AdminGroup    string   `yaml:"admin_group"`
+	} `yaml:"sso"`
+}
+
+func (c *Config) domainBackend() domainBackend {
+	b := &staticDomainBackend{sets: make(map[string]stringSet)}
+	for kind, list := range c.AvailableDomains {
+		b.sets[kind] = newStringSetFromList(list)
+	}
+	return b
+}
+
+func (c *Config) validationConfig() *validationConfig {
+	return &validationConfig{
+		forbiddenUsernames: newStringSetFromList(c.ForbiddenUsernames),
+	}
+}
+
+func (c *Config) ssoValidator() (sso.Validator, error) {
+	pkey, err := ioutil.ReadFile(c.SSO.PublicKeyFile)
+	if err != nil {
+		return nil, err
+	}
+	return sso.NewValidator(pkey, c.SSO.Domain)
+}
diff --git a/validators.go b/validators.go
new file mode 100644
index 0000000000000000000000000000000000000000..224b8d2a4b8ac201e15e00768365acf65641866b
--- /dev/null
+++ b/validators.go
@@ -0,0 +1,277 @@
+package accountserver
+
+import (
+	"bufio"
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"regexp"
+	"strings"
+
+	"golang.org/x/net/publicsuffix"
+)
+
+// A domainBackend manages the list of domains users are allowed to request services on.
+type domainBackend interface {
+	GetAvailableDomains(context.Context, string) []string
+	IsAvailableDomain(context.Context, string, string) bool
+}
+
+// The checkBackend verifies if specific resources already exist or are available.
+type checkBackend interface {
+	HasAnyResource(context.Context, []string) (bool, error)
+}
+
+type validationConfig struct {
+	forbiddenUsernames stringSet
+}
+
+// A stringSet is just a list of strings with a quick membership test.
+type stringSet struct {
+	set  map[string]struct{}
+	list []string
+}
+
+func newStringSetFromList(list []string) stringSet {
+	set := make(map[string]struct{})
+	for _, s := range list {
+		set[s] = struct{}{}
+	}
+	return stringSet{set: set, list: list}
+}
+
+func (s stringSet) Contains(needle string) bool {
+	_, ok := s.set[needle]
+	return ok
+}
+
+func (s stringSet) List() []string {
+	return s.list
+}
+
+// A domainBackend that works with a static list of type-specific allowed domains.
+type staticDomainBackend struct {
+	sets map[string]stringSet
+}
+
+func (d *staticDomainBackend) GetAvailableDomains(_ context.Context, kind string) []string {
+	return d.sets[kind].List()
+}
+
+func (d *staticDomainBackend) IsAvailableDomain(_ context.Context, kind, domain string) bool {
+	return d.sets[kind].Contains(domain)
+}
+
+func loadStringSetFromFile(path string) (stringSet, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return stringSet{}, err
+	}
+	defer f.Close()
+
+	var list []string
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		line := scanner.Text()
+		if line == "" {
+			continue
+		}
+		list = append(list, line)
+	}
+	if err := scanner.Err(); err != nil {
+		return stringSet{}, err
+	}
+	return newStringSetFromList(list), nil
+}
+
+// ValidatorFunc is the generic interface for unstructured data field
+// (string) validators.
+type ValidatorFunc func(context.Context, string) error
+
+func notInSet(set stringSet) ValidatorFunc {
+	return func(_ context.Context, value string) error {
+		if set.Contains(value) {
+			return errors.New("reserved value")
+		}
+		return nil
+	}
+}
+
+func minLength(minLen int) ValidatorFunc {
+	return func(_ context.Context, value string) error {
+		if len(value) < minLen {
+			return fmt.Errorf("value must be at least %d characters", minLen)
+		}
+		return nil
+	}
+}
+
+func maxLength(maxLen int) ValidatorFunc {
+	return func(_ context.Context, value string) error {
+		if len(value) > maxLen {
+			return fmt.Errorf("value must be at most %d characters", maxLen)
+		}
+		return nil
+	}
+}
+
+func matchRegexp(rx *regexp.Regexp, errmsg string) ValidatorFunc {
+	return func(_ context.Context, value string) error {
+		if !rx.MatchString(value) {
+			return errors.New(errmsg)
+		}
+		return nil
+	}
+}
+
+var usernameRx = regexp.MustCompile(`^([a-z0-9]+[.-]?)+[a-z0-9]$`)
+
+func matchUsernameRx() ValidatorFunc {
+	return matchRegexp(usernameRx, "value must be an alphanumeric sequence, including . and - characters but not starting or ending with those")
+}
+
+func matchSitenameRx() ValidatorFunc {
+	return matchUsernameRx()
+}
+
+func validateUsernameAndDomain(validateUsername, validateDomain ValidatorFunc) ValidatorFunc {
+	return func(ctx context.Context, value string) error {
+		parts := strings.SplitN(value, "@", 2)
+		if len(parts) != 2 {
+			return errors.New("malformed email address")
+		}
+		if err := validateUsername(ctx, parts[0]); err != nil {
+			return err
+		}
+		return validateDomain(ctx, parts[1])
+	}
+}
+
+// This is not a prescriptive domain name validation regex, it's just used to
+// give useful feedback to the user if the domain name looks visibly wrong.
+var domainRx = regexp.MustCompile(`^(?:[a-zA-Z0-9_\-]{1,63}\.)+(?:[a-zA-Z]{2,})$`)
+
+func isRegistered(domain string) bool {
+	// TODO: ha ha, there isn't really a good way to check.
+	return true
+}
+
+func validDomainName(value string) error {
+	if !domainRx.MatchString(value) {
+		return errors.New("invalid domain name")
+	}
+
+	// Extract the top-level domain and check that it is registered
+	// (which works also if we're hosting the top-level domain
+	// ourselves already).
+	domain, err := publicsuffix.EffectiveTLDPlusOne(value)
+	if err != nil {
+		return errors.New("invalid TLD")
+	}
+	if !isRegistered(domain) {
+		return fmt.Errorf("the domain %s does not seem to be registered", domain)
+	}
+	return nil
+}
+
+// Split a (correctly formed) email address into username/domain.
+func splitEmailAddr(addr string) (string, string) {
+	parts := strings.SplitN(addr, "@", 2)
+	return parts[0], parts[1]
+}
+
+// Returns all the possible resources in the email and mailing list
+// namespaces that might conflict with the given email address.
+func relatedEmails(ctx context.Context, be domainBackend, addr string) []string {
+	resourceIDs := []string{fmt.Sprintf("%s/%s", ResourceTypeEmail, addr)}
+	user, _ := splitEmailAddr(addr)
+	// Mailing lists must have unique names regardless of the domain, so we
+	// add potential conflicts for mailing lists with the same name over all
+	// list-enabled domains.
+	for _, d := range be.GetAvailableDomains(ctx, ResourceTypeMailingList) {
+		resourceIDs = append(resourceIDs, fmt.Sprintf("%s/%s@%s", ResourceTypeMailingList, user, d))
+	}
+	return resourceIDs
+}
+
+func splitSubsite(value string) (string, string) {
+	parts := strings.SplitN(value, "/", 2)
+	return parts[0], parts[1]
+}
+
+func isSubsite(value string) bool {
+	return strings.Contains(value, "/")
+}
+
+func relatedWebsites(ctx context.Context, be domainBackend, value string) []string {
+	var resourceIDs []string
+	if isSubsite(value) {
+		_, path := splitSubsite(value)
+		for _, d := range be.GetAvailableDomains(ctx, ResourceTypeWebsite) {
+			resourceIDs = append(resourceIDs, fmt.Sprintf("%s/%s/%s", ResourceTypeWebsite, d, path))
+		}
+	} else {
+		resourceIDs = append(resourceIDs, fmt.Sprintf("%s/%s", ResourceTypeWebsite, value))
+	}
+	return resourceIDs
+}
+
+func isAvailableEmailHostingDomain(be domainBackend) ValidatorFunc {
+	return func(ctx context.Context, value string) error {
+		if !be.IsAvailableDomain(ctx, ResourceTypeEmail, value) {
+			return errors.New("unavailable domain")
+		}
+		return nil
+	}
+}
+
+func isAvailableMailingListDomain(be domainBackend) ValidatorFunc {
+	return func(ctx context.Context, value string) error {
+		if !be.IsAvailableDomain(ctx, ResourceTypeMailingList, value) {
+			return errors.New("unavailable domain")
+		}
+		return nil
+	}
+}
+
+func isAvailableEmailAddr(be domainBackend, cb checkBackend) ValidatorFunc {
+	return func(ctx context.Context, value string) error {
+		rel := relatedEmails(ctx, be, value)
+		if ok, _ := cb.HasAnyResource(ctx, rel); ok {
+			return errors.New("address unavailable")
+		}
+		return nil
+	}
+}
+
+func validHostedEmail(config *validationConfig, be domainBackend, cb checkBackend) ValidatorFunc {
+	return allOf(
+		validateUsernameAndDomain(
+			allOf(matchUsernameRx(), minLength(4), notInSet(config.forbiddenUsernames)),
+			allOf(isAvailableEmailHostingDomain(be)),
+		),
+		isAvailableEmailAddr(be, cb),
+	)
+}
+
+func validHostedMailingList(config *validationConfig, be domainBackend, cb checkBackend) ValidatorFunc {
+	return allOf(
+		validateUsernameAndDomain(
+			allOf(matchUsernameRx(), minLength(4), notInSet(config.forbiddenUsernames)),
+			allOf(isAvailableMailingListDomain(be)),
+		),
+		isAvailableEmailAddr(be, cb),
+	)
+}
+
+func allOf(funcs ...ValidatorFunc) ValidatorFunc {
+	return func(ctx context.Context, value string) error {
+		for _, f := range funcs {
+			if err := f(ctx, value); err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+}
diff --git a/validators_test.go b/validators_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..6dbbae6be6f34ef6b2281cd7f78fd11bc8840efb
--- /dev/null
+++ b/validators_test.go
@@ -0,0 +1,127 @@
+package accountserver
+
+import (
+	"context"
+	"testing"
+)
+
+type validationTestData struct {
+	value string
+	ok    bool
+}
+
+func runValidationTest(t testing.TB, v ValidatorFunc, testData []validationTestData) {
+	for _, td := range testData {
+		err := v(context.Background(), td.value)
+		if (err == nil && !td.ok) || (err != nil && td.ok) {
+			t.Errorf("test for '%s' failed: expected %v, got error %v", td.value, td.ok, err)
+		}
+	}
+}
+
+func TestValidator_NotInSet(t *testing.T) {
+	td := []validationTestData{
+		{"apple", true},
+		{"pear", false},
+		{"", true},
+	}
+	runValidationTest(t, notInSet(newStringSetFromList([]string{"pear", "banana"})), td)
+}
+
+func TestValidator_MinMaxLength(t *testing.T) {
+	td := []validationTestData{
+		{"a", false},
+		{"aaa", true},
+		{"aaaaa", true},
+		{"aaaaaaa", false},
+		{"aaaaaaaaa", false},
+	}
+	runValidationTest(t, allOf(minLength(2), maxLength(5)), td)
+}
+
+func TestValidator_MatchUsername(t *testing.T) {
+	td := []validationTestData{
+		{"a", false},
+		{"aa", true},
+		{"abc", true},
+		{"123", true},
+		{"a-b", true},
+		{"a_b", false},
+		{"a-b-c-d", true},
+		{"-ab", false},
+		{"ab-", false},
+		{"a--b", false},
+		{"...", false},
+		{"a.b", true},
+		{"a.-b", false},
+		{"a-.-b", false},
+	}
+	runValidationTest(t, matchUsernameRx(), td)
+}
+
+type fakeCheckBackend map[string]struct{}
+
+func newFakeCheckBackend(rids ...string) fakeCheckBackend {
+	f := make(fakeCheckBackend)
+	for _, rid := range rids {
+		f[rid] = struct{}{}
+	}
+	return f
+}
+
+func (f fakeCheckBackend) HasAnyResource(_ context.Context, ids []string) (bool, error) {
+	for _, id := range ids {
+		if _, ok := f[id]; ok {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+func newTestValidationConfig(entries ...string) *validationConfig {
+	return &validationConfig{
+		forbiddenUsernames: newStringSetFromList(entries),
+	}
+}
+
+func newFakeDomainBackend(domains ...string) domainBackend {
+	set := newStringSetFromList(domains)
+	return &staticDomainBackend{
+		sets: map[string]stringSet{
+			ResourceTypeEmail:       set,
+			ResourceTypeMailingList: set,
+			ResourceTypeWebsite:     set,
+		},
+	}
+}
+
+func TestValidator_HostedEmail(t *testing.T) {
+	td := []validationTestData{
+		{"user1@example.com", true},
+		{"user2@example.com", true},
+		{".@example.com", false},
+		{"user@other-domain.com", false},
+		{"forbidden@example.com", false},
+		{"existing@example.com", false},
+	}
+	vc := newTestValidationConfig("forbidden")
+	cb := newFakeCheckBackend("email/existing@example.com")
+	db := newFakeDomainBackend("example.com")
+	runValidationTest(t, validHostedEmail(vc, db, cb), td)
+}
+
+func TestValidator_HostedMailingList(t *testing.T) {
+	td := []validationTestData{
+		{"list1@domain1.com", true},
+		{"list2@domain2.com", true},
+		{".@domain1.com", false},
+		{"list@other-domain.com", false},
+		{"forbidden@domain1.com", false},
+		{"existing@domain1.com", false},
+		{"existing@domain2.com", false},
+	}
+	vc := newTestValidationConfig("forbidden")
+	cb := newFakeCheckBackend("list/existing@domain2.com")
+	db := newFakeDomainBackend("domain1.com", "domain2.com")
+	runValidationTest(t, validHostedMailingList(vc, db, cb), td)
+}